mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-29 16:48:29 +00:00
Merge branch 'main' into juancastro/put-1798-sharing-email-notification-changes
This commit is contained in:
@@ -56,7 +56,7 @@ Both are supported ways to define an API. **Prefer a controller when you need fi
|
||||
- **Driver methods** get their policies from the `@Driver` options: per-method `rateLimit` (limit/window/backend), `concurrent` in-flight caps (optionally `bySubscription`), `requireSubscription`, `requireReputation`, and `noUserSession`. The `/drivers/call` surface enforces them.
|
||||
- **A surface only paying accounts should reach** declares `requireSubscription` — the route option on a controller endpoint, the per-method `@Driver({ requireSubscription })` block on a driver (`/drivers/call` is one shared route, so a driver's requirement can't live in route options). `true` accepts any plan that isn't free, so a plan registered by an extension counts without core naming it; an array of policy ids (`['business', 'pro']`) accepts only those; `false` is "no requirement", the same as leaving it out. An empty array is a boot error rather than a silent no-op — it reads as subscribers-only while admitting everyone. Off unless asked for, and answered from the metering service's cached per-actor subscription, so it costs a map lookup. Distinct from `requireCredits`: this asks which plan the account is on, not whether it has budget left. Deployments with no paid plans (self-hosted installs) turn the whole thing off with `meteringEnforcement.subscriptions: false`.
|
||||
- **A surface only a trusted-enough account should reach** declares `requireReputation` — the route option on a controller endpoint, the per-method `@Driver({ requireReputation })` block on a driver. The value names a tier; what that tier takes is `reputationGate.tiers` in config, so the score a surface is worth is a deployment call and can be retuned without editing the surface. A tier the running config doesn't define is inert and everyone passes — an install that doesn't score its accounts must not start turning traffic away on a score it never computed — and `reputationGate.enabled: false` stops every declared gate at once. Opt-in, implies `requireAuth`, and denies with a bare 403 `reputation_required` that names neither the score nor the tier. Nothing in the tree declares one yet: the mechanism ships ahead of the enrollment.
|
||||
- **An account must have verified a specific factor** — `requireVerified` (email, and only under `strict_email_verification_required`), `requirePhoneVerified`, `requireCardVerified`. These are opt-in and require the factor to have actually been verified, unlike the default-on gate that turns away accounts still carrying a pending verification the abuse harness asked for. `requireAnyVerified: ['phone', 'card']` is the OR of the last two: any listed factor verified at any point passes; otherwise only the factors the deployment can verify are asked for (an SMS provider configured, a card gate an extension reports on), and with none verifiable the gate is inert rather than locking the route on a self-hosted install. It denies with the first verifiable factor's code plus `factors`, the verifiable ones in the route's order, so a client can lead with one flow and offer the other. `verifiedFactorGate.enabled: false` in config stops every declared gate at once, for an SMS-provider outage.
|
||||
- **An account must have verified a specific factor** — `requireVerified` (email, and only under `strict_email_verification_required`), `requirePhoneVerified`, `requireCardVerified`. These are opt-in and require the factor to have actually been verified, unlike the default-on gate that turns away accounts still carrying a pending verification the abuse harness asked for. `requireAnyVerified: ['phone', 'card']` is the OR of the last two: any listed factor verified at any point passes, as does a paid plan where `card` is listed (a paying account has a card on file already); otherwise only the factors the deployment can verify are asked for (an SMS provider configured, a card gate an extension reports on), and with none verifiable the gate is inert rather than locking the route on a self-hosted install. It denies with the first verifiable factor's code plus `factors`, the verifiable ones in the route's order, so a client can lead with one flow and offer the other. `verifiedFactorGate.enabled: false` in config stops every declared gate at once, for an SMS-provider outage.
|
||||
- **An endpoint that spends metered resources** on the caller's behalf — moving file content, making object-store requests, anything else the account is billed for — also declares `requireCredits: true`, which turns an account with nothing left of its budget away with a 402 before the handler runs. Endpoints that only describe or delete things deliberately don't: an account that has run out still has to be able to see what it has, clear it, and reach its billing pages. Drivers have no route options to declare this on, so they call `assertActorHasCredits` themselves ([src/backend/services/metering/enforcement.ts](../src/backend/services/metering/enforcement.ts)) — see `KVStoreDriver`, which does it once for every method.
|
||||
|
||||
### 3. puter.js
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { toMicroCents } from '../../services/metering/utils';
|
||||
|
||||
// Microcents per byte of TURN egress ($0.05/GB).
|
||||
export const PEER_COSTS = {
|
||||
'turn:egress-bytes': 0.005,
|
||||
'turn:egress-bytes': toMicroCents(0.1 / 1000 ** 3),
|
||||
} as const;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createTestUser,
|
||||
setupPuterTestEnv,
|
||||
@@ -112,6 +112,27 @@ describe('share verification gate over HTTP', () => {
|
||||
expect(await res.json()).toMatchObject({ status: 'success' });
|
||||
});
|
||||
|
||||
it('lets a paying owner share without either factor', async () => {
|
||||
const owner = await makeUser();
|
||||
const row = await env.server.stores.user.getByUsername(owner.username);
|
||||
const metering = env.server.services.metering;
|
||||
const real = metering.getActorSubscription.bind(metering);
|
||||
const spy = vi
|
||||
.spyOn(metering, 'getActorSubscription')
|
||||
.mockImplementation(async (actor) =>
|
||||
actor.user?.uuid === row!.uuid
|
||||
? ({ id: 'business' } as never)
|
||||
: real(actor),
|
||||
);
|
||||
try {
|
||||
const file = await makeFile(owner);
|
||||
expect((await share(owner, file.uid)).status).toBe(200);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
metering.invalidateActorSubscription(row!.uuid);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts a verified card once an extension reports the card gate on', async () => {
|
||||
const cardGateOn = (
|
||||
_key: string,
|
||||
|
||||
@@ -19,10 +19,7 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
resetCardVerificationStatusCache,
|
||||
type CardFallbackDeps,
|
||||
} from '../../../util/cardFallback';
|
||||
import { resetCardVerificationStatusCache } from '../../../util/cardFallback';
|
||||
import { makeActor, type Actor } from '../../actor';
|
||||
import { HttpError, isHttpError } from '../HttpError';
|
||||
import {
|
||||
@@ -32,6 +29,7 @@ import {
|
||||
assertNotUserSession,
|
||||
noUserSessionGate,
|
||||
requireAnyVerifiedGate,
|
||||
type AnyVerifiedDeps,
|
||||
requireAuthGate,
|
||||
requireCardVerifiedGate,
|
||||
requirePhoneVerifiedGate,
|
||||
@@ -93,6 +91,23 @@ const runGate = (
|
||||
return captured;
|
||||
};
|
||||
|
||||
/** `runGate` for a gate that answers asynchronously. */
|
||||
const runGateAsync = (
|
||||
gate: (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: (arg?: unknown) => void,
|
||||
) => unknown,
|
||||
req: Partial<Request>,
|
||||
): Promise<NextArg> => {
|
||||
if (req.actor) req = { ...req, actor: reviveActor(req.actor) };
|
||||
return new Promise((resolve) =>
|
||||
gate(req as Request, {} as Response, (arg?: unknown) =>
|
||||
resolve(arg as NextArg),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const expectHttpError = (got: NextArg, status: number, legacyCode?: string) => {
|
||||
expect(isHttpError(got)).toBe(true);
|
||||
const err = got as HttpError;
|
||||
@@ -952,22 +967,31 @@ describe('requirePhoneVerifiedGate', () => {
|
||||
});
|
||||
|
||||
describe('requireCardVerifiedGate', () => {
|
||||
it('passes a user with a verified card on file', () => {
|
||||
const got = runGate(requireCardVerifiedGate(), {
|
||||
const plan = (paid: boolean) => ({ hasPaidPlan: async () => paid });
|
||||
|
||||
it('passes a user with a verified card on file', async () => {
|
||||
const got = await runGateAsync(requireCardVerifiedGate(plan(false)), {
|
||||
actor: { user: { uuid: 'u-1', card_fingerprint: 'fp_1' } },
|
||||
});
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a user who never verified a card', () => {
|
||||
const got = runGate(requireCardVerifiedGate(), {
|
||||
it('passes a paying account as card-verified', async () => {
|
||||
const got = await runGateAsync(requireCardVerifiedGate(plan(true)), {
|
||||
actor: { user: { uuid: 'u-1' } },
|
||||
});
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a user who never verified a card', async () => {
|
||||
const got = await runGateAsync(requireCardVerifiedGate(plan(false)), {
|
||||
actor: { user: { uuid: 'u-1' } },
|
||||
});
|
||||
expectHttpError(got, 403, 'card_verification_required');
|
||||
});
|
||||
|
||||
it('rejects a user still carrying the card gate', () => {
|
||||
const got = runGate(requireCardVerifiedGate(), {
|
||||
it('rejects a user still carrying the card gate', async () => {
|
||||
const got = await runGateAsync(requireCardVerifiedGate(plan(false)), {
|
||||
actor: {
|
||||
user: {
|
||||
uuid: 'u-1',
|
||||
@@ -986,27 +1010,20 @@ describe('requireAnyVerifiedGate', () => {
|
||||
// The card probe is memoized module-wide; every case starts cold.
|
||||
beforeEach(() => resetCardVerificationStatusCache());
|
||||
|
||||
const deps = (sms: boolean, card: boolean | null): CardFallbackDeps => ({
|
||||
const deps = (
|
||||
sms: boolean,
|
||||
card: boolean | null,
|
||||
paid = false,
|
||||
): AnyVerifiedDeps => ({
|
||||
smsConfigured: () => sms,
|
||||
probeCardVerification: async () => card,
|
||||
hasPaidPlan: async () => paid,
|
||||
});
|
||||
|
||||
const runAsync = (
|
||||
gate: ReturnType<typeof requireAnyVerifiedGate>,
|
||||
req: Partial<Request>,
|
||||
): Promise<NextArg> => {
|
||||
if (req.actor) req = { ...req, actor: reviveActor(req.actor) };
|
||||
return new Promise((resolve) =>
|
||||
gate(req as Request, {} as Response, (arg?: unknown) =>
|
||||
resolve(arg as NextArg),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const both = ['phone', 'card'] as const;
|
||||
|
||||
it('is inert where neither factor can be verified', async () => {
|
||||
const got = await runAsync(
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(false, null)),
|
||||
{ actor: { user: { uuid: 'u-1' } } },
|
||||
);
|
||||
@@ -1014,7 +1031,7 @@ describe('requireAnyVerifiedGate', () => {
|
||||
});
|
||||
|
||||
it('passes a verified number whatever the deployment can do today', async () => {
|
||||
const got = await runAsync(
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(false, null)),
|
||||
{ actor: { user: { uuid: 'u-1', phone: '+15550000000' } } },
|
||||
);
|
||||
@@ -1022,15 +1039,50 @@ describe('requireAnyVerifiedGate', () => {
|
||||
});
|
||||
|
||||
it('passes a verified card', async () => {
|
||||
const got = await runAsync(
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(true, true)),
|
||||
{ actor: { user: { uuid: 'u-1', card_fingerprint: 'fp_1' } } },
|
||||
);
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('takes a paid plan as the card, verified by other means', async () => {
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(true, true, true)),
|
||||
{ actor: { user: { uuid: 'u-1' } } },
|
||||
);
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not let a paid plan stand in for a phone', async () => {
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(['phone'], deps(true, true, true)),
|
||||
{ actor: { user: { uuid: 'u-1' } } },
|
||||
);
|
||||
expectHttpError(got, 403, 'phone_verification_required');
|
||||
});
|
||||
|
||||
it('asks about the plan only once the row itself has not answered', async () => {
|
||||
let asked = 0;
|
||||
const counting: AnyVerifiedDeps = {
|
||||
...deps(true, true),
|
||||
hasPaidPlan: async () => {
|
||||
asked++;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
await runGateAsync(requireAnyVerifiedGate(both, counting), {
|
||||
actor: { user: { uuid: 'u-1', phone: '+15550000000' } },
|
||||
});
|
||||
expect(asked).toBe(0);
|
||||
await runGateAsync(requireAnyVerifiedGate(both, counting), {
|
||||
actor: { user: { uuid: 'u-1' } },
|
||||
});
|
||||
expect(asked).toBe(1);
|
||||
});
|
||||
|
||||
it('leads with the phone flow and names both factors when both are verifiable', async () => {
|
||||
const got = await runAsync(
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(true, true)),
|
||||
{ actor: { user: { uuid: 'u-1' } } },
|
||||
);
|
||||
@@ -1041,7 +1093,7 @@ describe('requireAnyVerifiedGate', () => {
|
||||
});
|
||||
|
||||
it('asks only for the factors the deployment can verify', async () => {
|
||||
const got = await runAsync(
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(false, true)),
|
||||
{ actor: { user: { uuid: 'u-1' } } },
|
||||
);
|
||||
@@ -1050,7 +1102,7 @@ describe('requireAnyVerifiedGate', () => {
|
||||
});
|
||||
|
||||
it('takes the lead factor from the route order', async () => {
|
||||
const got = await runAsync(
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(['card', 'phone'], deps(true, true)),
|
||||
{ actor: { user: { uuid: 'u-1' } } },
|
||||
);
|
||||
@@ -1061,7 +1113,7 @@ describe('requireAnyVerifiedGate', () => {
|
||||
});
|
||||
|
||||
it('does not count a factor still mid-verification', async () => {
|
||||
const got = await runAsync(
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(true, null)),
|
||||
{
|
||||
actor: {
|
||||
@@ -1078,7 +1130,7 @@ describe('requireAnyVerifiedGate', () => {
|
||||
});
|
||||
|
||||
it('rejects when there is no actor at all', async () => {
|
||||
const got = await runAsync(
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(true, null)),
|
||||
{},
|
||||
);
|
||||
|
||||
@@ -454,35 +454,62 @@ export const requirePhoneVerifiedGate = (): RequestHandler => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject unless the user has a card verified on file. 403
|
||||
* `card_verification_required`, matching the pending-verification gate so the
|
||||
* client shows the card flow it already has.
|
||||
* What the card-aware gates need beyond the user's own row: whether each factor
|
||||
* can be verified here (the fallback deps), and whether the account's plan
|
||||
* already vouches for it.
|
||||
*/
|
||||
export const assertCardVerified = (user: AccountGateUser | undefined): void => {
|
||||
if (hasVerifiedCard(user)) return;
|
||||
throw new HttpError(403, CARD_REQUIRED_MESSAGE, {
|
||||
legacyCode: 'card_verification_required',
|
||||
});
|
||||
};
|
||||
export interface AnyVerifiedDeps extends CardFallbackDeps {
|
||||
/**
|
||||
* Whether the actor is on a paid plan. A paying account has a card on file
|
||||
* with the billing provider, so it is treated as card-verified and never
|
||||
* asked to verify one again.
|
||||
*/
|
||||
hasPaidPlan: (actor: Actor) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Route-option form of {@link assertCardVerified} (`requireCardVerified`). */
|
||||
export const requireCardVerifiedGate = (): RequestHandler => {
|
||||
/**
|
||||
* The card factor by either proof: a card checked on the row, or a paid plan.
|
||||
* The row answers first, so the plan is only looked up when it has to be.
|
||||
*/
|
||||
const hasCardEvidence = async (
|
||||
actor: Actor | undefined,
|
||||
deps: Pick<AnyVerifiedDeps, 'hasPaidPlan'>,
|
||||
): Promise<boolean> =>
|
||||
hasVerifiedCard(actor?.user) ||
|
||||
(actor !== undefined && (await deps.hasPaidPlan(actor)));
|
||||
|
||||
/**
|
||||
* Reject unless the user is card-verified — by a card on file, or by a paid
|
||||
* plan. 403 `card_verification_required`, matching the pending-verification
|
||||
* gate so the client shows the card flow it already has. Route-option form:
|
||||
* `requireCardVerified`.
|
||||
*/
|
||||
export const requireCardVerifiedGate = (
|
||||
deps: Pick<AnyVerifiedDeps, 'hasPaidPlan'>,
|
||||
): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
try {
|
||||
assertCardVerified(req.actor?.user);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
hasCardEvidence(req.actor, deps).then((verified) => {
|
||||
if (verified) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
next(
|
||||
new HttpError(403, CARD_REQUIRED_MESSAGE, {
|
||||
legacyCode: 'card_verification_required',
|
||||
}),
|
||||
);
|
||||
}, next);
|
||||
};
|
||||
};
|
||||
|
||||
const isFactorVerified = (
|
||||
factor: VerificationFactor,
|
||||
user: AccountGateUser | undefined,
|
||||
): boolean =>
|
||||
factor === 'phone' ? hasVerifiedPhone(user) : hasVerifiedCard(user);
|
||||
actor: Actor | undefined,
|
||||
deps: Pick<AnyVerifiedDeps, 'hasPaidPlan'>,
|
||||
): Promise<boolean> =>
|
||||
factor === 'phone'
|
||||
? Promise.resolve(hasVerifiedPhone(actor?.user))
|
||||
: hasCardEvidence(actor, deps);
|
||||
|
||||
/**
|
||||
* Whether this deployment can put a user through the factor's flow at all: SMS
|
||||
@@ -500,7 +527,8 @@ const isFactorVerifiable = async (
|
||||
/**
|
||||
* Reject unless the user has verified at least one of `factors` — the phone and
|
||||
* card gates above joined by OR. A factor verified at any point counts,
|
||||
* whatever the deployment can do today. Failing that, only the factors the
|
||||
* whatever the deployment can do today; for `card` a paid plan counts too, as
|
||||
* it does in `requireCardVerified`. Failing that, only the factors the
|
||||
* deployment can currently verify are asked for; with none of them verifiable
|
||||
* the gate is inert, so a self-hosted install without SMS or a card gate never
|
||||
* has its route locked.
|
||||
@@ -510,11 +538,14 @@ const isFactorVerifiable = async (
|
||||
* offer the rest as alternatives.
|
||||
*/
|
||||
export const assertAnyVerified = async (
|
||||
user: AccountGateUser | undefined,
|
||||
actor: Actor | undefined,
|
||||
factors: readonly VerificationFactor[],
|
||||
deps: CardFallbackDeps,
|
||||
deps: AnyVerifiedDeps,
|
||||
): Promise<void> => {
|
||||
if (factors.some((factor) => isFactorVerified(factor, user))) return;
|
||||
// In the route's order, so a verified phone never costs the plan lookup.
|
||||
for (const factor of factors) {
|
||||
if (await isFactorVerified(factor, actor, deps)) return;
|
||||
}
|
||||
const verifiable: VerificationFactor[] = [];
|
||||
for (const factor of factors) {
|
||||
if (verifiable.includes(factor)) continue;
|
||||
@@ -538,10 +569,10 @@ export const assertAnyVerified = async (
|
||||
/** Route-option form of {@link assertAnyVerified} (`requireAnyVerified`). */
|
||||
export const requireAnyVerifiedGate = (
|
||||
factors: readonly VerificationFactor[],
|
||||
deps: CardFallbackDeps,
|
||||
deps: AnyVerifiedDeps,
|
||||
): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
assertAnyVerified(req.actor?.user, factors, deps).then(
|
||||
assertAnyVerified(req.actor, factors, deps).then(
|
||||
() => next(),
|
||||
(err) => next(err),
|
||||
);
|
||||
|
||||
@@ -248,10 +248,11 @@ export interface RouteOptions {
|
||||
* Reject unless the user has verified at least one of the named factors —
|
||||
* `requirePhoneVerified` / `requireCardVerified` joined by OR, for a
|
||||
* surface where either proof will do. A factor verified at any point
|
||||
* counts. Otherwise only the factors this deployment can verify are asked
|
||||
* for (an SMS provider configured; a card gate an extension reports on),
|
||||
* and with none of them verifiable the gate is inert rather than locking
|
||||
* the route on a self-hosted install.
|
||||
* counts, and so does a paid plan where `card` is listed — a paying account
|
||||
* has a card on file already. Otherwise only the factors this deployment
|
||||
* can verify are asked for (an SMS provider configured; a card gate an
|
||||
* extension reports on), and with none of them verifiable the gate is inert
|
||||
* rather than locking the route on a self-hosted install.
|
||||
*
|
||||
* 403 with the first verifiable factor's code
|
||||
* (`phone_verification_required` / `card_verification_required`) plus
|
||||
|
||||
@@ -27,10 +27,10 @@ export const KV_CACHED_READ_RATE_SHARE = 0.1;
|
||||
// Microcents per underlying DynamoDB capacity unit, as reported by
|
||||
// SystemKVStore.KVUsage. Cost is `KV_COSTS[op] * usage.<op>`.
|
||||
export const KV_COSTS = {
|
||||
'kv:read': 17,
|
||||
'kv:write': 90,
|
||||
'kv:read': 50,
|
||||
'kv:write': 250,
|
||||
// 10% of `kv:read` — kept as a literal so the reported rate is exactly this
|
||||
// and not a float artifact of the multiplication. The unit count is the one
|
||||
// the equivalent uncached read consumed.
|
||||
'kv:read:cached': 1.7,
|
||||
'kv:read:cached': 5,
|
||||
} as const;
|
||||
|
||||
+14
-6
@@ -35,6 +35,7 @@ import { createAuthProbe } from './core/http/middleware/authProbe';
|
||||
import { createRequestContextMiddleware } from './core/http/middleware/requestContext';
|
||||
import { createFingerprintMiddleware } from './core/http/middleware/fingerprint';
|
||||
import { createErrorHandler } from './core/http/middleware/errorHandler';
|
||||
import type { Actor } from './core/actor';
|
||||
import { isHttpError } from './core/http/HttpError';
|
||||
import {
|
||||
adminOnlyGate,
|
||||
@@ -55,7 +56,10 @@ import { requireCreditsGate } from './core/http/middleware/credits';
|
||||
import { requireReputationGate } from './core/http/middleware/reputation';
|
||||
import { requireSubscriptionGate } from './core/http/middleware/subscription';
|
||||
import { validateReputationRequirement } from './core/reputation';
|
||||
import { validateSubscriptionRequirement } from './services/metering/enforcement';
|
||||
import {
|
||||
actorOnPaidPlan,
|
||||
validateSubscriptionRequirement,
|
||||
} from './services/metering/enforcement';
|
||||
import { createStepUpGate } from './core/http/middleware/stepUpSession';
|
||||
import { createNotFoundHandler } from './core/http/middleware/notFoundHandler';
|
||||
import { cardFallbackDepsFrom } from './util/cardFallback';
|
||||
@@ -1086,8 +1090,12 @@ export class PuterServer {
|
||||
if (opts.requirePhoneVerified) {
|
||||
mwChain.push(requirePhoneVerifiedGate());
|
||||
}
|
||||
// A paying account has a card on file already, so both card-aware
|
||||
// gates take a paid plan as the card factor.
|
||||
const hasPaidPlan = (actor: Actor) =>
|
||||
actorOnPaidPlan(this.services.metering, actor);
|
||||
if (opts.requireCardVerified) {
|
||||
mwChain.push(requireCardVerifiedGate());
|
||||
mwChain.push(requireCardVerifiedGate({ hasPaidPlan }));
|
||||
}
|
||||
if (opts.requireAnyVerified) {
|
||||
// Same stance as the subscription requirement: an empty list reads
|
||||
@@ -1099,10 +1107,10 @@ export class PuterServer {
|
||||
}
|
||||
if (this.#config.verifiedFactorGate?.enabled !== false) {
|
||||
mwChain.push(
|
||||
requireAnyVerifiedGate(
|
||||
opts.requireAnyVerified,
|
||||
cardFallbackDepsFrom(this.clients),
|
||||
),
|
||||
requireAnyVerifiedGate(opts.requireAnyVerified, {
|
||||
...cardFallbackDepsFrom(this.clients),
|
||||
hasPaidPlan,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,10 +25,7 @@ import { isSystemActor, makeActor } from '../../core/actor';
|
||||
import { PermissionUtil } from '../permission/permissionUtil';
|
||||
import { MANAGE_PERM_PREFIX } from '../permission/consts';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import {
|
||||
subscriptionEnforcementEnabled,
|
||||
subscriptionSatisfies,
|
||||
} from '../metering/enforcement';
|
||||
import { actorHasSubscription } from '../metering/enforcement';
|
||||
|
||||
// -- Types ------------------------------------------------------------
|
||||
|
||||
@@ -274,16 +271,14 @@ export class ACLService extends PuterService {
|
||||
* map lookup once warm.
|
||||
*/
|
||||
async #planCoversLinkSharing(ownerUserId: number): Promise<boolean> {
|
||||
const metering = this.services.metering;
|
||||
if (!metering || !subscriptionEnforcementEnabled(this.config)) {
|
||||
return true;
|
||||
}
|
||||
const owner = await this.stores.user.getById(ownerUserId);
|
||||
if (!owner?.uuid) return false;
|
||||
const subscription = await metering.getActorSubscription(
|
||||
if (!owner) return false;
|
||||
return actorHasSubscription(
|
||||
this.services.metering,
|
||||
makeActor({ user: owner }),
|
||||
true,
|
||||
this.config,
|
||||
);
|
||||
return subscriptionSatisfies(subscription.id, true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1272,6 +1272,25 @@ describe('FSService signed (direct-to-S3) writes', () => {
|
||||
await fs.abortUrlWrite(user.userId, response.sessionId);
|
||||
});
|
||||
|
||||
it('keeps a zero-byte write single even when multipart is requested', async () => {
|
||||
// Multipart on a zero declared size is how a caller reaches for a part
|
||||
// URL carrying no size limit, against a quota check that saw nothing.
|
||||
const response = await fs.startUrlWrite(user.userId, {
|
||||
fileMetadata: {
|
||||
path: `${user.home}/Documents/empty.bin`,
|
||||
size: 0,
|
||||
contentType: 'application/octet-stream',
|
||||
},
|
||||
uploadMode: 'multipart',
|
||||
});
|
||||
|
||||
expect(response.uploadMode).toBe('single');
|
||||
expect(response.multipartUploadId).toBeFalsy();
|
||||
expect(response.multipartPartUrls).toBeFalsy();
|
||||
|
||||
await fs.abortUrlWrite(user.userId, response.sessionId);
|
||||
});
|
||||
|
||||
it('aborts the multipart upload when the pending row cannot be written', async () => {
|
||||
const abort = vi.spyOn(server.stores.s3Object, 'abortMutipartUpload');
|
||||
const createPendingEntry = vi
|
||||
|
||||
@@ -769,6 +769,11 @@ export class FSService extends PuterService {
|
||||
): UploadMode {
|
||||
const maxSingleUploadSize =
|
||||
this.stores.s3Object.getMaxSingleUploadSize();
|
||||
// An empty object has no part to carry it, so a multipart request
|
||||
// here can only be an attempt at a part URL bound to no bytes.
|
||||
if (size <= 0) {
|
||||
return 'single';
|
||||
}
|
||||
if (requestUploadMode === 'multipart') {
|
||||
return 'multipart';
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ const BYTES_PER_GIB = 1024 * 1024 * 1024;
|
||||
* by the same door and cost the same per byte (~$0.12/GiB).
|
||||
*/
|
||||
export const EGRESS_COSTS = {
|
||||
'egress:bytes': toMicroCents(0.12 / BYTES_PER_GIB),
|
||||
'egress:bytes': toMicroCents(0.2 / BYTES_PER_GIB),
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -37,8 +37,8 @@ export const EGRESS_COSTS = {
|
||||
* than one large one — which is what these price in. Removals are free.
|
||||
*/
|
||||
export const STORAGE_OP_COSTS = {
|
||||
'storage:write:ops': toMicroCents(0.005 / 1000),
|
||||
'storage:read:ops': toMicroCents(0.0004 / 1000),
|
||||
'storage:write:ops': toMicroCents(0.01 / 1000),
|
||||
'storage:read:ops': toMicroCents(0.001 / 1000),
|
||||
'storage:delete:ops': 0,
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ import { SYSTEM_ACTOR, type Actor } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { IConfig } from '../../types';
|
||||
import {
|
||||
actorHasSubscription,
|
||||
actorOnPaidPlan,
|
||||
assertActorHasCredits,
|
||||
assertActorHasSubscription,
|
||||
creditEnforcementExempt,
|
||||
@@ -154,6 +156,87 @@ const onPlan = (id: string) => ({
|
||||
getActorSubscription: vi.fn().mockResolvedValue({ id }),
|
||||
});
|
||||
|
||||
describe('actorHasSubscription', () => {
|
||||
const metering = (id: string) => ({
|
||||
getActorSubscription: vi.fn().mockResolvedValue({ id }),
|
||||
});
|
||||
const user: Actor = { user: { uuid: 'u-1' } } as Actor;
|
||||
const config = {} as IConfig;
|
||||
|
||||
it('answers the plan question as a yes or no', async () => {
|
||||
expect(
|
||||
await actorHasSubscription(
|
||||
metering('business'),
|
||||
user,
|
||||
true,
|
||||
config,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await actorHasSubscription(
|
||||
metering('user_free'),
|
||||
user,
|
||||
true,
|
||||
config,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is a yes wherever the assertion would not ask', async () => {
|
||||
expect(
|
||||
await actorHasSubscription(
|
||||
metering('user_free'),
|
||||
user,
|
||||
false,
|
||||
config,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(await actorHasSubscription(undefined, user, true, config)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
await actorHasSubscription(metering('user_free'), user, true, {
|
||||
meteringEnforcement: { subscriptions: false },
|
||||
} as IConfig),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await actorHasSubscription(
|
||||
metering('user_free'),
|
||||
SYSTEM_ACTOR,
|
||||
true,
|
||||
config,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is a no for an actor with no account behind it', async () => {
|
||||
const asked = metering('business');
|
||||
expect(await actorHasSubscription(asked, undefined, true, config)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(asked.getActorSubscription).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('actorOnPaidPlan', () => {
|
||||
const metering = (id: string) => ({
|
||||
getActorSubscription: vi.fn().mockResolvedValue({ id }),
|
||||
});
|
||||
const user: Actor = { user: { uuid: 'u-1' } } as Actor;
|
||||
|
||||
it('reads the plan as a fact, whatever the enforcement switches say', async () => {
|
||||
expect(await actorOnPaidPlan(metering('business'), user)).toBe(true);
|
||||
expect(await actorOnPaidPlan(metering('user_free'), user)).toBe(false);
|
||||
});
|
||||
|
||||
it('is a no with nothing to ask, or nobody to ask about', async () => {
|
||||
expect(await actorOnPaidPlan(undefined, user)).toBe(false);
|
||||
const asked = metering('business');
|
||||
expect(await actorOnPaidPlan(asked, undefined)).toBe(false);
|
||||
expect(asked.getActorSubscription).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscriptionSatisfies', () => {
|
||||
it('counts anything that is not a free policy as a subscription', () => {
|
||||
expect(subscriptionSatisfies('user_free', true)).toBe(false);
|
||||
|
||||
@@ -168,6 +168,59 @@ export const subscriptionEnforcementEnabled = (
|
||||
enforcementEnabled(config) &&
|
||||
config.meteringEnforcement?.subscriptions !== false;
|
||||
|
||||
/**
|
||||
* The cases in which no plan is asked about at all: nothing was required, there
|
||||
* is no metering to ask, plan gates are switched off, or the caller is the
|
||||
* system. Shared by the check and the assertion so the two cannot drift.
|
||||
*/
|
||||
const subscriptionCheckWaived = (
|
||||
metering: SubscriptionMetering | undefined,
|
||||
actor: Actor | undefined,
|
||||
requirement: SubscriptionRequirement,
|
||||
config: EnforcementConfig,
|
||||
): boolean =>
|
||||
requirement === false ||
|
||||
(Array.isArray(requirement) && requirement.length === 0) ||
|
||||
!metering ||
|
||||
!subscriptionEnforcementEnabled(config) ||
|
||||
Boolean(actor && isSystemActor(actor));
|
||||
|
||||
/**
|
||||
* Whether an actor's plan covers a plan-gated surface right now. The same
|
||||
* question {@link assertActorHasSubscription} asks of a caller, as a yes or no —
|
||||
* for an account that is not the caller (an owner whose link share is being
|
||||
* read or listed), where there is no request to refuse.
|
||||
*/
|
||||
export const actorHasSubscription = async (
|
||||
metering: SubscriptionMetering | undefined,
|
||||
actor: Actor | undefined,
|
||||
requirement: SubscriptionRequirement,
|
||||
config: EnforcementConfig,
|
||||
): Promise<boolean> => {
|
||||
if (subscriptionCheckWaived(metering, actor, requirement, config)) {
|
||||
return true;
|
||||
}
|
||||
if (!actor?.user?.uuid) return false;
|
||||
const subscription = await metering!.getActorSubscription(actor);
|
||||
return subscriptionSatisfies(subscription.id, requirement);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the actor is on a paid plan — the fact, not the gate. Unlike
|
||||
* {@link actorHasSubscription} this ignores the enforcement switches: it is for
|
||||
* a caller reading the plan as evidence (a paying account has a card on file
|
||||
* with the billing provider) rather than as an entitlement to enforce. False
|
||||
* with no metering to ask, or no account behind the actor.
|
||||
*/
|
||||
export const actorOnPaidPlan = async (
|
||||
metering: SubscriptionMetering | undefined,
|
||||
actor: Actor | undefined,
|
||||
): Promise<boolean> => {
|
||||
if (!metering || !actor?.user?.uuid) return false;
|
||||
const subscription = await metering.getActorSubscription(actor);
|
||||
return subscriptionSatisfies(subscription.id, true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject a caller whose plan doesn't cover the surface they're calling.
|
||||
*
|
||||
@@ -185,11 +238,7 @@ export const assertActorHasSubscription = async (
|
||||
requirement: SubscriptionRequirement,
|
||||
config: EnforcementConfig,
|
||||
): Promise<void> => {
|
||||
if (requirement === false) return;
|
||||
if (Array.isArray(requirement) && requirement.length === 0) return;
|
||||
if (!metering) return;
|
||||
if (!subscriptionEnforcementEnabled(config)) return;
|
||||
if (actor && isSystemActor(actor)) return;
|
||||
if (subscriptionCheckWaived(metering, actor, requirement, config)) return;
|
||||
|
||||
if (!actor?.user?.uuid) {
|
||||
throw new HttpError(403, 'A subscription is required for this action', {
|
||||
|
||||
@@ -679,12 +679,36 @@ describe('ShareService', () => {
|
||||
owner.user.uuid,
|
||||
);
|
||||
expect(await canRead(stranger.actor, file.path)).toBe(false);
|
||||
// A link nobody can use is not shown as a share anywhere: the
|
||||
// item's own listing, the owner's outbound one, or the badge.
|
||||
expect(
|
||||
await server.services.share.listSharesOf(owner.actor, {
|
||||
uid: file.uuid,
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
(await server.services.share.listSharedByMe(owner.actor)).items,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
await server.services.share.shareFlags(owner.actor, [file]),
|
||||
).toEqual(new Map([[file.uuid, false]]));
|
||||
|
||||
paid.add(owner.user.uuid);
|
||||
server.services.metering.invalidateActorSubscription(
|
||||
owner.user.uuid,
|
||||
);
|
||||
expect(await canRead(stranger.actor, file.path)).toBe(true);
|
||||
// The row was never dropped, so the plan brings it back as it was.
|
||||
expect(
|
||||
await server.services.share.listSharesOf(owner.actor, {
|
||||
uid: file.uuid,
|
||||
}),
|
||||
).toEqual([
|
||||
expect.objectContaining({ anyone: true, mode: 'read' }),
|
||||
]);
|
||||
expect(
|
||||
await server.services.share.shareFlags(owner.actor, [file]),
|
||||
).toEqual(new Map([[file.uuid, true]]));
|
||||
});
|
||||
|
||||
it('hands out access, never authority', async () => {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { contentType as contentTypeFromMime } from 'mime-types';
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import { userRelatedActor, type Actor } from '../../core/actor';
|
||||
import { makeActor, userRelatedActor, type Actor } from '../../core/actor';
|
||||
import { HttpError, isHttpError } from '../../core/http/HttpError.js';
|
||||
import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js';
|
||||
import { isUniqueViolation } from '../../util/dbError.js';
|
||||
@@ -38,7 +38,10 @@ import {
|
||||
maskEntryPath,
|
||||
resolveSharePath,
|
||||
} from '../fs/sharePathMask';
|
||||
import { assertActorHasSubscription } from '../metering/enforcement.js';
|
||||
import {
|
||||
actorHasSubscription,
|
||||
assertActorHasSubscription,
|
||||
} from '../metering/enforcement.js';
|
||||
import { MANAGE_PERM_PREFIX } from '../permission/consts';
|
||||
import { PermissionUtil } from '../permission/permissionUtil.js';
|
||||
import { PuterService } from '../types';
|
||||
@@ -1741,6 +1744,12 @@ export class ShareService extends PuterService {
|
||||
this.#liveGroupGrants(rows, nodeById),
|
||||
]);
|
||||
|
||||
// Link rows are the caller's own (owner-only by construction), so one
|
||||
// answer covers them all; only asked when there is one to show.
|
||||
const linkCovered = rows.some((row) => row.anyone)
|
||||
? await this.#linkSharingCovered(actor)
|
||||
: true;
|
||||
|
||||
const items: ResolvedShare[] = [];
|
||||
for (const row of rows) {
|
||||
const entry = entries.get(Number(row.fsentry_id));
|
||||
@@ -1754,8 +1763,10 @@ export class ShareService extends PuterService {
|
||||
if (pending && !pendingAllowed.has(row.uid)) continue;
|
||||
// Its liveness is the grant, not a holder's reach.
|
||||
if (anyone) {
|
||||
// Owner-only by construction; a node that changed hands had
|
||||
// its rows dropped, so this only catches a row that slipped.
|
||||
// Silent while the plan is lapsed, so not listed either.
|
||||
if (!linkCovered) continue;
|
||||
// A node that changed hands had its rows dropped, so this only
|
||||
// catches a row that slipped.
|
||||
if (entry.userId !== Number(row.issuer_user_id)) continue;
|
||||
} else if (row.holder_group_id) {
|
||||
if (!liveGroupGrants.has(row.uid)) continue;
|
||||
@@ -2093,12 +2104,18 @@ export class ShareService extends PuterService {
|
||||
),
|
||||
)
|
||||
).flat();
|
||||
// And so can anyone with the link to a folder above it.
|
||||
const anyoneRows: OutboundShareRow[] =
|
||||
// And so can anyone with the link to a folder above it — while the
|
||||
// owner's plan covers it. A link the ACL turns away is not a share the
|
||||
// owner should see listed as one; it is silent, and stays silent until
|
||||
// the plan is back.
|
||||
let anyoneRows: OutboundShareRow[] =
|
||||
await this.stores.share.listAnyoneOnFsentries([
|
||||
entry.id,
|
||||
...viaById.keys(),
|
||||
]);
|
||||
if (anyoneRows.length > 0 && !(await this.#linkSharingCovered(entry))) {
|
||||
anyoneRows = [];
|
||||
}
|
||||
const userIds = [
|
||||
...[...rows, ...inherited.map((i) => i.row)].flatMap(
|
||||
(row: { issuer_user_id: number; holder_user_id: number }) => [
|
||||
@@ -2219,8 +2236,10 @@ export class ShareService extends PuterService {
|
||||
);
|
||||
if (own.length === 0) return new Map();
|
||||
|
||||
// A link share nobody can use must not badge the item as shared.
|
||||
const sharedIds = await this.stores.share.getSharedFsentryIds(
|
||||
own.map((entry) => entry.id),
|
||||
{ includeAnyone: await this.#linkSharingCovered(actor) },
|
||||
);
|
||||
return new Map(
|
||||
own.map((entry) => [entry.uuid, sharedIds.has(entry.id)]),
|
||||
@@ -2600,6 +2619,28 @@ export class ShareService extends PuterService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether link shares on this owner's items work right now — the ACL's
|
||||
* question, asked here so no listing shows a link the ACL would turn away.
|
||||
* Takes the owner's actor, or the entry to look the owner up from.
|
||||
*/
|
||||
async #linkSharingCovered(owner: Actor | FSEntry): Promise<boolean> {
|
||||
let actor: Actor | undefined;
|
||||
if ('user' in owner) {
|
||||
actor = owner;
|
||||
} else {
|
||||
const row = await this.stores.user.getById(owner.userId);
|
||||
if (!row) return false;
|
||||
actor = makeActor({ user: row });
|
||||
}
|
||||
return actorHasSubscription(
|
||||
this.services.metering,
|
||||
actor,
|
||||
true,
|
||||
this.config,
|
||||
);
|
||||
}
|
||||
|
||||
/** Link sharing is the owner's call, on and off alike. */
|
||||
#assertOwnsLinkShare(entry: FSEntry, userId: number): void {
|
||||
if (entry.userId === userId) return;
|
||||
|
||||
@@ -336,6 +336,48 @@ describe('S3ObjectStore object round trips', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('binds a part URL to zero bytes when the declared total size is zero', async () => {
|
||||
const key = uuidv4();
|
||||
const opened = await store().createSignedUploadUrl(
|
||||
{
|
||||
bucket,
|
||||
objectKey: key,
|
||||
size: store().getMultipartPartSize() * 2,
|
||||
contentType: 'application/octet-stream',
|
||||
uploadMode: 'multipart',
|
||||
expiresInSeconds: 900,
|
||||
},
|
||||
region,
|
||||
);
|
||||
|
||||
// A zero declared size leaves no bytes for the part, which has to bind
|
||||
// as zero rather than fall through to an unbound URL.
|
||||
const partUrls = await store().createSignedMultipartPartUrls(
|
||||
{
|
||||
bucket,
|
||||
objectKey: key,
|
||||
multipartUploadId: opened.multipartUploadId as string,
|
||||
partNumbers: [1],
|
||||
expiresInSeconds: 900,
|
||||
declaredTotalSize: 0,
|
||||
multipartPartSize: store().getMultipartPartSize(),
|
||||
},
|
||||
region,
|
||||
);
|
||||
|
||||
const signedHeaders = new URL(
|
||||
partUrls[0]?.url as string,
|
||||
).searchParams.get('X-Amz-SignedHeaders');
|
||||
expect(signedHeaders).toContain('content-length');
|
||||
|
||||
await store().abortMutipartUpload(
|
||||
opened.multipartUploadId as string,
|
||||
region,
|
||||
bucket,
|
||||
key,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves part URLs unbound when the declared sizes are not supplied', async () => {
|
||||
const key = uuidv4();
|
||||
const opened = await store().createSignedUploadUrl(
|
||||
|
||||
@@ -302,7 +302,10 @@ export class S3ObjectStore extends PuterStore {
|
||||
}
|
||||
const offset = (partNumber - 1) * multipartPartSize;
|
||||
const remaining = declaredTotalSize - offset;
|
||||
if (remaining <= 0) return undefined;
|
||||
// Zero, not `undefined`: a declared size of 0 leaves nothing for
|
||||
// the part to carry, and an omitted binding is an unbounded URL
|
||||
// rather than a small one.
|
||||
if (remaining <= 0) return 0;
|
||||
return Math.min(multipartPartSize, remaining);
|
||||
};
|
||||
|
||||
|
||||
@@ -371,12 +371,15 @@ export class ShareStore extends PuterStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of `fsentryIds` carry a share, pending invites included.
|
||||
* Which of `fsentryIds` carry a share, pending invites included. Link
|
||||
* shares count unless `includeAnyone` is false — what the caller passes
|
||||
* while the owner's plan has them switched off.
|
||||
*
|
||||
* @param {number[]} fsentryIds
|
||||
* @param {{ includeAnyone?: boolean }} [opts]
|
||||
* @returns {Promise<Set<number>>}
|
||||
*/
|
||||
async getSharedFsentryIds(fsentryIds) {
|
||||
async getSharedFsentryIds(fsentryIds, { includeAnyone = true } = {}) {
|
||||
const ids = [
|
||||
...new Set(
|
||||
fsentryIds.map(Number).filter((id) => Number.isFinite(id)),
|
||||
@@ -388,7 +391,8 @@ export class ShareStore extends PuterStore {
|
||||
const placeholders = chunk.map(() => '?').join(', ');
|
||||
const rows = await this.clients.db.read(
|
||||
'SELECT DISTINCT `fsentry_id` FROM `share` ' +
|
||||
`WHERE \`fsentry_id\` IN (${placeholders})`,
|
||||
`WHERE \`fsentry_id\` IN (${placeholders})` +
|
||||
(includeAnyone ? '' : ' AND `anyone` IS NULL'),
|
||||
chunk,
|
||||
);
|
||||
for (const row of rows) shared.add(Number(row.fsentry_id));
|
||||
|
||||
@@ -44,7 +44,7 @@ A `Promise` that resolves to an array of share objects, each with `uid`, `mode`,
|
||||
|
||||
The list includes shares granted by **anyone** holding `manage` on the item, not only your own. That is how an owner sees what someone they trusted has re-shared.
|
||||
|
||||
If the item is open to **anyone with the link** (see [`share()`](/FS/share/)), that share is listed too, with `anyone: true` and a `null` `holder` — inherited from a folder above when the folder is what was opened.
|
||||
If the item is open to **anyone with the link** (see [`share()`](/FS/share/)), that share is listed too, with `anyone: true` and a `null` `holder` — inherited from a folder above when the folder is what was opened. It is left out while the owner's plan does not cover link sharing, because nobody can use it then.
|
||||
|
||||
It also includes **invitations** — shares aimed at an email address with no confirmed account yet. Those carry `pending: true`, a `null` `holder`, and the address in `recipientEmail`. They grant nothing until the recipient confirms that address, and [`unshare()`](/FS/unshare/) cancels one before it is claimed.
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ If some recipients succeed and others fail, the promise resolves with the ones t
|
||||
|
||||
A rejection carries `{ message, code }`. Because each recipient/item pair succeeds or fails on its own, these are the codes of the *pairs* that failed — you only see one as a rejection when every pair failed.
|
||||
|
||||
One refusal applies to the whole call instead: handing out access requires a verified phone number or a verified card on the account, on deployments that can verify either. The rejection is `phone_verification_required` (or `card_verification_required` where only a card can be verified) and carries `factors`, the verifications the deployment accepts, in the order to offer them. Inside the Puter desktop the user is walked through it and the call is retried on its own. Withdrawing and listing shares never ask for this.
|
||||
One refusal applies to the whole call instead: handing out access requires a verified phone number or a verified card on the account, on deployments that can verify either. An account on a paid plan is never asked — its card is already on file. The rejection is `phone_verification_required` (or `card_verification_required` where only a card can be verified) and carries `factors`, the verifications the deployment accepts, in the order to offer them. Inside the Puter desktop the user is walked through it and the call is retried on its own. Withdrawing and listing shares never ask for this.
|
||||
|
||||
| `code` | Meaning |
|
||||
| --- | --- |
|
||||
@@ -117,7 +117,7 @@ await puter.fs.unshare('report.txt', { anyone: true });
|
||||
Three things set it apart from sharing with a person:
|
||||
|
||||
- **It is the owner's call.** Someone holding `manage` on the item can share it with people, but not open it to everyone; they get `forbidden`.
|
||||
- **It is a paid-plan feature.** A free account is refused with `subscription_required`. The plan is checked again every time the link is used, so while the owner has no plan the link is silent — nobody has to find it and take it back — and it works again once they do.
|
||||
- **It is a paid-plan feature.** A free account is refused with `subscription_required`. The plan is checked again every time the link is used, so while the owner has no plan the link is silent — nobody has to find it and take it back — and it is not listed as a share by [`getShares()`](/FS/getShares/) or [`listSharedByMe()`](/FS/listSharedByMe/) either, since nobody can use it. The share itself is kept: once the owner is on a plan again the link works, and is listed, exactly as it was.
|
||||
- **Nobody is told.** No notification goes out, and the item does not appear in anyone's [`listShared()`](/FS/listShared/); whoever has the link opens it from the link.
|
||||
|
||||
The share shows in [`getShares()`](/FS/getShares/) with `anyone: true` and a `null` `holder`. Sharing again with a different mode replaces it, as it does for a person.
|
||||
|
||||
Reference in New Issue
Block a user