Compare commits

...
11 Commits
Author SHA1 Message Date
ProgrammerIn-wonderlandandClaude Opus 5.5 2f35dadb28 fix(hosting): choose the site and subdomain from Host, not X-Forwarded-Host
With trust proxy on, express derives req.hostname and req.subdomains from
X-Forwarded-Host, which any client can send, while host validation reads the
raw Host. A request could serve one hosted site's files on the main domain,
or reach the API on a hosting host. The header is now dropped before routing.

Fixes PUT-2272

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 18:32:03 -05:00
Daniel Salazar 58456ae87c fix: signup gate order (#4474) 2026-11-02 13:27:01 -07:00
abb70c14a6 docs: complex fs recipe (PUT-1978) (#4093)
* docs: complex fs recipe (PUT-1978)

A drop folder that reacts to files landing in it, using `puter.events` with an
`fs:` subject instead of polling `readdir`.

One use case per the recipe guidance: watch, ignore your own writes, recover
from a gap, stop watching. The gap section works out what is already done from
the folder itself rather than a counter, so a dropped delivery costs a listing
and never a file.

* docs: rework fs recipe into a CSV importer (PUT-1978)

Renames the recipe to "Import Files From a Folder" and reframes it around
importing CSV files with puter.fs and onLocal(). Watches the app's own
inbox folder, imports files moved in from another folder, and catches up
on open and on gaps. Drops the event.self check, which skipped every file
the user added.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* refine 2

---------

Co-authored-by: Reynaldi Chernando <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-03 03:05:34 +07:00
Daniel Salazar 18fd655645 fix: sec improvements (#4469) 2026-11-02 12:11:38 -07:00
dependabot[bot] 480fa7b85b chore(deps): bump fast-uri from 3.1.8 to 3.1.9 (#4462)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.8 to 3.1.9.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.8...v3.1.9)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-02 00:05:11 -07:00
dependabot[bot] 47b54aacb4 chore(deps): bump postal-mime from 3.0.1 to 4.2.0 (#4461)
Bumps [postal-mime](https://github.com/postalsys/postal-mime) from 3.0.1 to 4.2.0.
- [Release notes](https://github.com/postalsys/postal-mime/releases)
- [Changelog](https://github.com/postalsys/postal-mime/blob/master/CHANGELOG.md)
- [Commits](https://github.com/postalsys/postal-mime/compare/v3.0.1...v4.2.0)

---
updated-dependencies:
- dependency-name: postal-mime
  dependency-version: 4.2.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-02 00:05:00 -07:00
dependabot[bot] dd3c0dd485 chore(deps): bump qs from 6.16.0 to 6.17.1 (#4460)
Bumps [qs](https://github.com/ljharb/qs) from 6.16.0 to 6.17.1.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.16.0...v6.17.1)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.17.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-02 00:04:37 -07:00
dependabot[bot] e0689986ed chore(deps): bump systeminformation from 5.33.1 to 5.33.19 (#4425)
Bumps [systeminformation](https://github.com/sebhildebrandt/systeminformation) from 5.33.1 to 5.33.19.
- [Release notes](https://github.com/sebhildebrandt/systeminformation/releases)
- [Changelog](https://github.com/sebhildebrandt/systeminformation/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sebhildebrandt/systeminformation/compare/v5.33.1...v5.33.19)

---
updated-dependencies:
- dependency-name: systeminformation
  dependency-version: 5.33.19
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 23:55:47 -07:00
dependabot[bot] 0f54588b31 chore(deps): bump qs from 6.16.0 to 6.17.1 in /src/puter-js (#4427)
Bumps [qs](https://github.com/ljharb/qs) from 6.16.0 to 6.17.1.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.16.0...v6.17.1)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.17.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 23:55:21 -07:00
dependabot[bot] 0cb9f7707d chore(deps): bump follow-redirects in /src/puter-js (#4424)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.16.1 to 1.16.2.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.16.1...v1.16.2)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-version: 1.16.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 23:55:00 -07:00
dependabot[bot] 622b782f4e chore(deps): bump follow-redirects from 1.16.1 to 1.16.2 (#4426)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.16.1 to 1.16.2.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.16.1...v1.16.2)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-version: 1.16.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 23:54:48 -07:00
26 changed files with 905 additions and 217 deletions

No files matched your search

+21 -28
View File
@@ -9555,9 +9555,9 @@
"license": "Unlicense"
},
"node_modules/fast-uri": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
"integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
"funding": [
{
"type": "github",
@@ -9869,9 +9869,9 @@
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz",
"integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==",
"funding": [
{
"type": "individual",
@@ -14244,10 +14244,13 @@
}
},
"node_modules/postal-mime": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-3.0.1.tgz",
"integrity": "sha512-N86EnYOxZjW1uHQIgSXnnRdnJz+wxmtDsnk3C4R/9rQ1utj8A2znauXZ3Q3+stCHxG5ckY4/NrO09WiPU6rcdQ==",
"license": "MIT-0"
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-4.2.0.tgz",
"integrity": "sha512-QG5Rh8wY/zjwFF1lKWcBbwWcAWR5jWodmMPL4gMik43XDLebtWoAAPyG0lrE2kYYZ5IHTASrepGuGO0YIwNKAg==",
"license": "MIT-0",
"engines": {
"node": ">=20"
}
},
"node_modules/postcss": {
"version": "8.5.26",
@@ -14525,9 +14528,9 @@
"link": true
},
"node_modules/qs": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz",
"integrity": "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
@@ -15996,9 +15999,9 @@
}
},
"node_modules/systeminformation": {
"version": "5.33.1",
"resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.33.1.tgz",
"integrity": "sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w==",
"version": "5.33.19",
"resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.33.19.tgz",
"integrity": "sha512-Zm7wGeCG7PqfvzegMJIzrxICopWqYCeLATVmvxOOci1isLcNVKgY4Fr0hhYQzvc7cgtK3377jQVChTFAnznOiA==",
"license": "MIT",
"os": [
"darwin",
@@ -17999,16 +18002,6 @@
}
}
},
"node_modules/wrangler/node_modules/undici": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/wrangler/node_modules/workerd": {
"version": "1.20260915.1",
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260915.1.tgz",
@@ -18311,7 +18304,7 @@
"openai": "^6.34.0",
"otpauth": "^9.5.2",
"pg": "^8.21.0",
"postal-mime": "^3.0.1",
"postal-mime": "^4.2.0",
"replicate": "^1.0.0",
"sharp": "^0.35.5",
"smtp-server": "^3.19.24",
@@ -19130,7 +19123,7 @@
"@heyputer/kv.js": "^0.2.1",
"open": "^10.2.0",
"path-browserify": "1.0.1",
"postal-mime": "^3.0.1",
"postal-mime": "^4.2.0",
"socket.io-client": "^4.8.4"
},
"devDependencies": {
+18 -5
View File
@@ -323,6 +323,8 @@ export type EventMap = {
// the abuse extension cap sends per device (across accounts) so a
// device farm can't dodge the per-account cap with fresh signups.
device_fingerprint: string | null;
// Browser-signals dispatch id the client sent, or null.
dispatch_id: string | null;
allowed: boolean;
reason: string | null;
// With `allowed` false, offer the card fallback in place of SMS. Only
@@ -950,6 +952,9 @@ export type AiCostFactorEvent = {
*/
export type LifecyclePhase = 'before' | 'after' | 'error' | 'reject';
/** Status a vetoing `before` listener may answer with. */
export type LifecycleRejectStatus = 403 | 429;
/**
* Payload for `driver.<iface>.<method>.<phase>` events.
*
@@ -979,6 +984,8 @@ export type DriverMethodLifecycleEvent = {
allow?: boolean;
/** Optional human-readable reason surfaced to the caller when vetoed. */
rejectReason?: string;
/** Status for a veto: 403 (default) or 429 for a throttle. */
rejectStatus?: LifecycleRejectStatus;
};
/**
@@ -1013,6 +1020,8 @@ export type RouteLifecycleEvent = {
allow?: boolean;
/** Optional human-readable reason surfaced to the caller when vetoed. */
rejectReason?: string;
/** Status for a veto: 403 (default) or 429 for a throttle. */
rejectStatus?: LifecycleRejectStatus;
};
export type EventKey = keyof EventMap & string;
@@ -1021,10 +1030,11 @@ export type EventKey = keyof EventMap & string;
// Generates a wildcard for every non-final dot-separated prefix of K.
export type WildcardPrefixes<K extends string> =
K extends `${infer Head}.${infer Tail}`
? | `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
?
| `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
: never;
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
@@ -1035,7 +1045,10 @@ export type MatchingEvents<P extends ListenKey> = P extends `${infer Prefix}.*`
/** Acked, not acked, no socket anywhere, or sockets only in a peer region. */
export type NotifDeliveryOutcome =
'received' | 'unreceived' | 'no-socket' | 'remote-only';
| 'received'
| 'unreceived'
| 'no-socket'
| 'remote-only';
/** One socket's receipt for a live notification. */
export interface NotifDeliveryResponder {
@@ -5132,6 +5132,26 @@ describe('AuthController phone verification — staging & reuse', () => {
expect(staged).toBe('+14155550123');
});
it('makes an email-gate-last account verify its card before its phone', async () => {
const { user, actor } = await makeUserAndActor({
requires_phone_verification: 1,
requires_card_verification: 1,
});
await server.stores.user.updateMetadata(user.id, {
emailGateLast: true,
});
const prelude = stubPrelude();
await withClients({ prelude }, async () => {
await expect(
controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 409 });
});
expect(prelude.createVerification).not.toHaveBeenCalled();
});
it('forwards an abuse-extension veto as 429 with the opaque reason, and sends nothing', async () => {
const { user, actor } = await makeUserAndActor();
const emitAndWait = vi.fn(
@@ -5197,7 +5217,7 @@ describe('AuthController phone verification — staging & reuse', () => {
);
});
it('carries the client dispatch id on the sent signal', async () => {
it('carries the client dispatch id on the check and sent events', async () => {
const { actor } = await makeUserAndActor();
const emit = vi.fn();
const emitAndWait = vi.fn(async () => {});
@@ -5218,6 +5238,11 @@ describe('AuthController phone verification — staging & reuse', () => {
expect.objectContaining({ dispatch_id: 'disp-123' }),
expect.anything(),
);
expect(emitAndWait).toHaveBeenCalledWith(
'puter.phone-verification.check',
expect.objectContaining({ dispatch_id: 'disp-123' }),
expect.anything(),
);
});
it('does NOT emit the sent signal when the send fails upstream', async () => {
+34 -2
View File
@@ -66,6 +66,7 @@ import {
isCardFallbackEnabled,
phoneAttemptsKey,
} from '../../util/cardFallback.js';
import { isEmailGateLast } from '../../util/verificationOrder.js';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { cleanEmail, isStorableEmail } from '../../util/email.js';
import { isGodmodeApp } from '../../util/godmodeApps.js';
@@ -1220,6 +1221,18 @@ export class AuthController extends PuterController {
}
}
/** The email-gate-last confirmation email; a failure never fails the gate. */
private async sendDeferredConfirmation(userId: number): Promise<void> {
try {
await this.services.signup.sendDeferredConfirmationEmail(userId);
} catch (e) {
console.warn(
'[verification] deferred confirmation email failed:',
e,
);
}
}
private async isCardFallbackEligible(user: {
id: number;
requires_phone_verification?: boolean | number | null;
@@ -1265,6 +1278,12 @@ export class AuthController extends PuterController {
throw new HttpError(403, 'Account suspended.', {
legacyCode: 'account_suspended',
});
if (isEmailGateLast(user) && user.requires_card_verification)
throw new HttpError(
409,
'Card verification must be completed first.',
{ legacyCode: 'conflict' },
);
if (!this.clients.prelude?.isConfigured())
throw await this.smsSendError(
503,
@@ -1335,6 +1354,7 @@ export class AuthController extends PuterController {
user_uid: user.uuid,
phone: parsed.e164,
device_fingerprint: req.deviceFingerprint ?? null,
dispatch_id: dispatchId ?? null,
allowed: true,
reason: null as string | null,
card_fallback_instead: false,
@@ -1573,6 +1593,7 @@ export class AuthController extends PuterController {
requires_phone_verification: 0,
phone: pendingPhone,
});
await this.sendDeferredConfirmation(user.id);
// Run the verified-event listeners synchronously (emitAndWait, not
// fire-and-forget emit) so a carrier-based card-verification waiver in
@@ -1653,7 +1674,11 @@ export class AuthController extends PuterController {
res.json({ card_verified: true });
return;
}
if (user.requires_phone_verification && !fallbackEligible)
if (
user.requires_phone_verification &&
!fallbackEligible &&
!isEmailGateLast(user)
)
throw new HttpError(
409,
'Phone verification must be completed first.',
@@ -1716,6 +1741,7 @@ export class AuthController extends PuterController {
await this.stores.user.update(user.id, {
requires_card_verification: 0,
});
await this.sendDeferredConfirmation(user.id);
res.json({ card_verified: true, disabled: true });
return;
}
@@ -1775,7 +1801,11 @@ export class AuthController extends PuterController {
res.json({ card_verified: true });
return;
}
if (user.requires_phone_verification && !fallbackEligible)
if (
user.requires_phone_verification &&
!fallbackEligible &&
!isEmailGateLast(user)
)
throw new HttpError(
409,
'Phone verification must be completed first.',
@@ -1816,6 +1846,7 @@ export class AuthController extends PuterController {
await this.stores.user.update(user.id, {
requires_card_verification: 0,
});
await this.sendDeferredConfirmation(user.id);
res.json({ card_verified: true, disabled: true });
return;
}
@@ -1840,6 +1871,7 @@ export class AuthController extends PuterController {
requires_card_verification: 0,
...(clearedPhoneGate ? { requires_phone_verification: 0 } : {}),
});
await this.sendDeferredConfirmation(user.id);
// Awaited like `user.phone-verified`, so whatever a listener grants on
// verification is in place before the client refreshes. emitAndWait
@@ -736,6 +736,42 @@ describe('DriverController driver-method lifecycle events', () => {
rejectReason: 'blocked in test',
});
});
it('answers 429 when a before listener vetoes with rejectStatus 429', async () => {
const actor = await makeUserActor();
await grantKvPerm(actor);
const key = `throttle-${uuidv4()}`;
server.clients.event.on(
'driver.puter-kvstore.set.before',
(_k, data) => {
if ((data.args as { key?: string })?.key === key) {
data.allow = false;
data.rejectStatus = 429;
}
},
);
const req = makeReq(
{
interface: 'puter-kvstore',
method: 'set',
args: { key, value: 'v' },
},
actor,
);
await expect(
runWithContext({ actor }, () =>
routes['POST /call'](
req,
makeRes() as unknown as Response,
() => {},
),
),
).rejects.toMatchObject({
statusCode: 429,
legacyCode: 'too_many_requests',
});
});
});
describe('DriverController.#handleListInterfaces', () => {
@@ -31,6 +31,7 @@ import {
checkDriverRateLimit,
} from '../../core/http/middleware/rateLimit.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
import type { LifecycleRejectStatus } from '../../clients/event/types.js';
import type { DriverMeta } from '../../drivers/meta.js';
import {
isCreditExhaustion,
@@ -334,7 +335,8 @@ export class DriverController extends PuterController {
if (req.actor) {
const permService = this.services.permission as unknown as
PermissionService | undefined;
| PermissionService
| undefined;
if (permService) {
// Build via PermissionUtil.join so any `:` in a driver or
// interface name is escaped — raw interpolation would let a
@@ -475,6 +477,7 @@ export class DriverController extends PuterController {
args,
allow: true as boolean,
rejectReason: undefined as string | undefined,
rejectStatus: undefined as LifecycleRejectStatus | undefined,
};
await this.clients.event?.emitAndWait(
`driver.${ifaceName}.${method}.before`,
@@ -496,11 +499,12 @@ export class DriverController extends PuterController {
},
{},
);
const throttled = beforeEvent.rejectStatus === 429;
throw new HttpError(
403,
throttled ? 429 : 403,
beforeEvent.rejectReason ??
`Blocked by policy: ${ifaceName}:${method}`,
{ legacyCode: 'forbidden' },
{ legacyCode: throttled ? 'too_many_requests' : 'forbidden' },
);
}
@@ -836,6 +836,34 @@ describe('requireVerifiedAccount', () => {
expectHttpError(got, 403, 'card_verification_required');
});
it('asks an email-gate-last account for card, then phone, then email', () => {
const pending = {
uuid: 'u-1',
requires_email_confirmation: true,
email_confirmed: false,
requires_phone_verification: true,
requires_card_verification: true,
metadata: { emailGateLast: true },
};
const gateFor = (user: Record<string, unknown>) =>
runGate(requireVerifiedAccount(), { actor: { user } });
expectHttpError(gateFor(pending), 403, 'card_verification_required');
expectHttpError(
gateFor({ ...pending, requires_card_verification: false }),
403,
'phone_verification_required',
);
expectHttpError(
gateFor({
...pending,
requires_card_verification: false,
requires_phone_verification: false,
}),
403,
'email_confirmation_required',
);
});
it('returns 403 two_factor_required for a seat whose team requires 2FA', async () => {
const teams = { getOrgSeat: async () => ({ require_2fa: 1 }) };
const got = await runGateAsync(requireTeam2fa(teams), {
+22 -10
View File
@@ -32,6 +32,7 @@ import {
type CardFallbackDeps,
} from '../../../util/cardFallback';
import { reserveUsernames } from '../../../util/reservedUsernames';
import { isEmailGateLast } from '../../../util/verificationOrder';
import {
isAccountContext,
isAppActor,
@@ -309,11 +310,25 @@ export const requireVerifiedAccount = (): RequestHandler =>
export const assertVerifiedAccount = (
user: AccountGateUser | undefined,
): void => {
if (user?.requires_email_confirmation && !user?.email_confirmed) {
throw new HttpError(403, 'Please confirm your email to continue', {
legacyCode: 'email_confirmation_required',
});
}
const assertEmail = () => {
if (user?.requires_email_confirmation && !user?.email_confirmed) {
throw new HttpError(403, 'Please confirm your email to continue', {
legacyCode: 'email_confirmation_required',
});
}
};
const assertCard = () => {
if (user?.requires_card_verification) {
throw new HttpError(403, 'Please verify your card to continue', {
legacyCode: 'card_verification_required',
});
}
};
// Email → phone → card, or card → phone → email for an email-gate-last
// account, so the client is sent to the gate it has to clear next.
const emailLast = isEmailGateLast(user);
if (emailLast) assertCard();
else assertEmail();
if (user?.requires_phone_verification) {
throw new HttpError(
403,
@@ -323,11 +338,8 @@ export const assertVerifiedAccount = (
},
);
}
if (user?.requires_card_verification) {
throw new HttpError(403, 'Please verify your card to continue', {
legacyCode: 'card_verification_required',
});
}
if (emailLast) assertEmail();
else assertCard();
// A team seat signs in on a password its administrator still holds,
// so it reaches nothing until it has replaced that password.
if (user?.requires_password_change) {
@@ -290,6 +290,26 @@ describe('createRouteLifecycleMiddleware', () => {
expect(after).toHaveLength(0);
});
it('answers 429 when the vetoing listener asks for it', async () => {
const events = makeEvents();
events.on(`${BASE}.before` as never, (_k, data) => {
(data as RouteLifecycleEvent).allow = false;
(data as RouteLifecycleEvent).rejectReason = 'slow down';
(data as RouteLifecycleEvent).rejectStatus = 429;
});
const reject = record(events, `${BASE}.reject` as keyof EventMap);
const mw = createRouteLifecycleMiddleware(events, POST, PATH);
const res = new FakeRes();
await mw(makeReq('u-9'), res as unknown as Response, () => {});
expect(res.statusCode).toBe(429);
expect(res.body).toMatchObject({
error: { code: 'too_many_requests', message: 'slow down' },
});
expect(reject[0]).toMatchObject({ statusCode: 429 });
});
it('treats a listener-sent response as a terminal after (not a reject) with the real status', async () => {
const events = makeEvents();
events.on(`${BASE}.before` as never, (_k, data) => {
+9 -5
View File
@@ -20,6 +20,7 @@
import type { Request, RequestHandler } from 'express';
import { actorUid } from '../actor';
import type { EventClient } from '../../clients/event/EventClient';
import type { LifecycleRejectStatus } from '../../clients/event/types';
import type { RouteMethod } from './types';
type RoutePathValue = string | RegExp | Array<string | RegExp> | undefined;
@@ -100,30 +101,33 @@ export const createRouteLifecycleMiddleware = (
...base,
allow: true as boolean,
rejectReason: undefined as string | undefined,
rejectStatus: undefined as LifecycleRejectStatus | undefined,
};
if (observeBefore) {
await events.emitAndWait(beforeKey, beforeEvent, {});
}
// Explicit veto: a listener set `allow = false`. Emit `reject` and
// answer 403 — unless the listener already wrote its own response, in
// which case we report that status rather than a misleading 403.
// answer 403 (or the listener's 429) — unless the listener already
// wrote its own response, in which case we report that status.
if (beforeEvent.allow === false) {
const status = beforeEvent.rejectStatus === 429 ? 429 : 403;
events.emit(
`${keyBase}.reject`,
{
phase: 'reject',
...base,
statusCode: res.headersSent ? res.statusCode : 403,
statusCode: res.headersSent ? res.statusCode : status,
durationMs: Date.now() - startedAt,
rejectReason: beforeEvent.rejectReason,
},
{},
);
if (!res.headersSent) {
res.status(403).json({
res.status(status).json({
error: {
code: 'forbidden',
code:
status === 429 ? 'too_many_requests' : 'forbidden',
message:
beforeEvent.rejectReason ?? 'Blocked by policy',
},
+1
View File
@@ -76,6 +76,7 @@ export type AccountGateUser = Partial<
| 'phone'
| 'card_fingerprint'
| 'otp_enabled'
| 'metadata'
>
> & { id?: number };
+1 -1
View File
@@ -57,7 +57,7 @@
"openai": "^6.34.0",
"otpauth": "^9.5.2",
"pg": "^8.21.0",
"postal-mime": "^3.0.1",
"postal-mime": "^4.2.0",
"replicate": "^1.0.0",
"sharp": "^0.35.5",
"smtp-server": "^3.19.24",
+53
View File
@@ -472,6 +472,59 @@ describe('PuterServer host header validation — permissive modes', () => {
});
});
describe('PuterServer behind a trusted proxy', () => {
let server: PuterServer;
let port: number;
beforeAll(async () => {
port = await allocateEphemeralPort();
server = await setupTestServer(
{
port,
domain: 'puter.localhost',
origin: `http://puter.localhost:${port}`,
api_base_url: `http://api.puter.localhost:${port}`,
allow_all_host_values: false,
allow_no_host_header: false,
custom_domains_enabled: false,
static_hosting_domain: 'site.puter.localhost',
trust_proxy: 1,
} as unknown as IConfig,
{ listen: true },
);
});
afterAll(async () => {
await server?.shutdown();
});
it('serves the main domain whatever X-Forwarded-Host names', async () => {
const res = await rawRequest(port, '/healthcheck', {
host: `puter.localhost:${port}`,
'x-forwarded-host': 'nosuchsite.site.puter.localhost',
});
expect(res.status).toBe(200);
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
});
it('keeps a hosted-site host on site hosting', async () => {
const res = await rawRequest(port, '/healthcheck', {
host: `nosuchsite.site.puter.localhost:${port}`,
'x-forwarded-host': 'api.puter.localhost',
});
expect(res.status).toBe(404);
});
it('reads subdomains from Host', async () => {
const res = await rawRequest(port, '/healthcheck', {
host: `api.puter.localhost:${port}`,
'x-forwarded-host': 'puter.localhost',
});
expect(res.status).toBe(200);
expect(res.headers['x-frame-options']).toBeUndefined();
});
});
describe('PuterServer host header validation — custom domains with every host accepted', () => {
let server: PuterServer;
let port: number;
+11
View File
@@ -413,6 +413,17 @@ export class PuterServer {
* `#materializeRoute` as those options ship.
*/
#installGlobalMiddleware() {
// -- Forwarded host ------------------------------------------
// With `trust proxy` set, express derives `req.hostname` and
// `req.subdomains` from X-Forwarded-Host, which any client can send.
// Dropping it keeps them on Host, the header host validation checks,
// so a request can't pick a different site or subdomain router than
// the host it was sent to.
this.#app.use((req, _res, next) => {
delete req.headers['x-forwarded-host'];
next();
});
// -- Egress metering -----------------------------------------
// First, so the byte counter wraps `res.write` before compression
// does and therefore counts what actually goes out rather than what
@@ -172,6 +172,87 @@ describe('SignupService.signup', () => {
});
});
describe('SignupService email gate last', () => {
/** Signs up with the validate hook asking for `gates`; returns sent emails. */
const signupWith = async (gates: Record<string, boolean>) => {
const send = vi.fn(async () => true);
const clients = server.clients as unknown as { email: unknown };
const realEmail = clients.email;
clients.email = { send };
const require = (_k: string, data: unknown) => {
Object.assign(data as Record<string, unknown>, gates);
};
server.clients.event.on('puter.signup.validate', require);
const username = `egl_${slug()}`;
try {
const user = await server.services.signup.signup({
req,
body: {
username,
email: `${username}@example.com`,
password: 'correct horse battery',
},
isTemp: false,
fingerprint: null,
bonusCode: null,
});
return { user, send, restore: () => (clients.email = realEmail) };
} finally {
server.clients.event.off('puter.signup.validate', require);
}
};
it('holds the confirmation email until card and phone are cleared, then sends it once', async () => {
const { user, send, restore } = await signupWith({
requires_phone_verification: true,
requires_card_verification: true,
email_gate_last: true,
});
try {
expect(send).not.toHaveBeenCalled();
const stored = await server.stores.user.getById(user.id, {
force: true,
});
expect(stored!.metadata?.emailGateLast).toBe(true);
await server.stores.user.update(user.id, {
requires_card_verification: 0,
});
await server.services.signup.sendDeferredConfirmationEmail(user.id);
expect(send).not.toHaveBeenCalled();
await server.stores.user.update(user.id, {
requires_phone_verification: 0,
});
await server.services.signup.sendDeferredConfirmationEmail(user.id);
await server.services.signup.sendDeferredConfirmationEmail(user.id);
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith(
`${user.username}@example.com`,
'email_verification_code',
{ code: stored!.email_confirm_code },
);
} finally {
restore();
}
});
it('ignores the flag when there is no phone or card gate', async () => {
const { user, send, restore } = await signupWith({
email_gate_last: true,
});
try {
expect(send).toHaveBeenCalledTimes(1);
const stored = await server.stores.user.getById(user.id, {
force: true,
});
expect(stored!.metadata?.emailGateLast).toBeUndefined();
} finally {
restore();
}
});
});
describe('SignupService.announceSignup', () => {
it('leaves out the form-only fields for a source that has none', async () => {
const username = `an_${slug()}`;
+49 -4
View File
@@ -32,6 +32,7 @@ import {
validateSignupBonus,
} from '../../util/signupBonus.js';
import { isUsernameTaken } from '../../util/username.js';
import { isEmailGateLast } from '../../util/verificationOrder.js';
import {
generateDefaultFsentries,
promoteToVerifiedGroup,
@@ -47,6 +48,8 @@ export type SignupGateOutcome =
requiresEmailConfirmation: boolean;
requiresPhoneVerification: boolean;
requiresCardVerification: boolean;
/** Card and phone come before email (see `isEmailGateLast`). */
emailGateLast: boolean;
}
| { verdict: 'bonus_code_invalid' }
| {
@@ -175,6 +178,9 @@ export class SignupService extends PuterService {
requires_email_confirmation: false,
requires_phone_verification: false,
requires_card_verification: false,
// Clear card and phone before email, and send the confirmation
// email only then. Meaningless without a phone or card gate.
email_gate_last: false,
message: null as string | null,
code: null as string | null,
...(input.fingerprint !== undefined ? { fingerprint } : {}),
@@ -250,6 +256,9 @@ export class SignupService extends PuterService {
),
requiresPhoneVerification,
requiresCardVerification,
emailGateLast:
Boolean(validateEvent.email_gate_last) &&
(requiresPhoneVerification || requiresCardVerification),
};
}
@@ -507,7 +516,10 @@ export class SignupService extends PuterService {
}
}
if (!isTemp && user.requires_email_confirmation) {
const emailGateLast = !isTemp && gate.emailGateLast;
if (emailGateLast) user = await this.#markEmailGateLast(user);
if (!isTemp && user.requires_email_confirmation && !emailGateLast) {
await this.sendConfirmationEmail(
user,
'signup',
@@ -582,13 +594,46 @@ export class SignupService extends PuterService {
}
}
/** Record that this account clears card and phone before email. */
async #markEmailGateLast(user: UserRow): Promise<UserRow> {
await this.stores.user.updateMetadata(user.id, { emailGateLast: true });
return { ...user, metadata: { ...user.metadata, emailGateLast: true } };
}
/**
* Send an email-gate-last account its confirmation email once card and
* phone are cleared. Once per account; `/send-confirm-email` resends.
*/
async sendDeferredConfirmationEmail(userId: number): Promise<void> {
const user = await this.stores.user.getById(userId, { force: true });
if (!user || !isEmailGateLast(user)) return;
if (user.metadata?.emailGateLastSent === true) return;
if (user.email_confirmed || !user.requires_email_confirmation) return;
if (user.requires_phone_verification || user.requires_card_verification)
return;
await this.stores.user.updateMetadata(user.id, {
emailGateLastSent: true,
});
let code =
typeof user.email_confirm_code === 'string'
? user.email_confirm_code
: null;
if (!code) {
code = String(crypto.randomInt(100000, 1000000));
await this.stores.user.update(user.id, {
email_confirm_code: code,
});
}
await this.sendConfirmationEmail(user, 'deferred', { code });
}
/**
* Send the confirmation code (or link) for the account's address. A lost
* message is alarmed rather than thrown: the account exists either way.
*/
async sendConfirmationEmail(
user: UserRow,
stage: 'signup' | 'resend',
stage: 'signup' | 'resend' | 'deferred',
message: { code: string } | { token: string },
): Promise<void> {
if (!this.clients.email || !user.email) return;
@@ -762,7 +807,7 @@ export class SignupService extends PuterService {
* phone/card gate is outstanding either.
*/
#confirmationEmailFailed(
stage: 'signup' | 'resend',
stage: 'signup' | 'resend' | 'deferred',
user: UserRow,
cause: unknown,
): void {
@@ -774,7 +819,7 @@ export class SignupService extends PuterService {
? 'no transport configured (message dropped)'
: String(cause);
console.warn(
`[${stage === 'signup' ? 'signup' : 'send-confirm-email'}] ` +
`[${stage === 'resend' ? 'send-confirm-email' : 'signup'}] ` +
`confirmation email not delivered: ${detail}`,
);
// Best-effort: failing to alarm must not fail the signup.
+40
View File
@@ -442,6 +442,46 @@ describe('secureFetch transport behaviour', () => {
});
});
it('follows redirects up to maxRedirects, each through the proxy', async () => {
respond = (res) => {
if (lastRequest.url === '/?url=https://upstream.test/start') {
res.writeHead(301, { location: '/landing' });
res.end();
return;
}
res.writeHead(200);
res.end('landed');
};
const res = await withProxy(() =>
secureFetch('https://upstream.test/start', { maxRedirects: 1 }),
);
expect(await res.text()).toBe('landed');
expect(lastRequest.url).toBe('/?url=https://upstream.test/landing');
});
it('re-validates each hop and stops at maxRedirects', async () => {
respond = (res) => {
res.writeHead(302, { location: 'http://169.254.169.254/' });
res.end();
};
await withProxy(async () => {
await expect(
secureFetch('https://upstream.test/a', { maxRedirects: 3 }),
).rejects.toThrow('IP-addressed URLs are not allowed');
});
respond = (res) => {
res.writeHead(302, { location: 'https://upstream.test/loop' });
res.end();
};
await withProxy(async () => {
await expect(
secureFetch('https://upstream.test/a', { maxRedirects: 2 }),
).rejects.toThrow('redirects are not allowed');
});
});
it('names the target as unknown when a 3xx carries no Location', async () => {
respond = (res) => {
res.writeHead(304);
+39 -16
View File
@@ -172,15 +172,21 @@ function proxyConfig(): ISecureCorsProxyConfig | undefined {
interface SecureFetchInit extends Omit<RequestInit, 'redirect'> {
/** Bypass the CORS proxy even if one is configured. Internal-only calls. */
skipProxy?: boolean;
/**
* Follow up to this many http(s) redirects (default 0). Every hop goes
* through the same guards as the first request.
*/
maxRedirects?: number;
}
/**
* Fetch `url` with SSRF guards: • Rejects raw-IP / localhost hosts via
* {@link validateUrlNoIP}. • Forces `redirect: 'manual'` and rejects any 3xx
* response so a permissive target can't bounce us onto an internal endpoint. •
* Re-checks DNS at connect time: every address the socket actually connects to
* must pass {@link isPublicResolvedAddress}, so a hostname resolving to a
* private/reserved address (including via DNS rebinding) is rejected. • If
* {@link validateUrlNoIP}. • Rejects any 3xx response so a permissive target
* can't bounce us onto an internal endpoint, unless `maxRedirects` allows it;
* each followed hop is re-validated. • Re-checks DNS at connect time: every
* address the socket actually connects to must pass
* {@link isPublicResolvedAddress}, so a hostname resolving to a private/reserved
* address (including via DNS rebinding) is rejected. • If
* `config.secureCorsProxy` is set AND the URL isn't a `data:` URI, rewrites the
* request through the configured signed Cloudflare Worker proxy (adds
* `x-cors-proxy-auth-secret`).
@@ -192,6 +198,33 @@ interface SecureFetchInit extends Omit<RequestInit, 'redirect'> {
export async function secureFetch(
url: string,
init: SecureFetchInit = {},
): Promise<Response> {
const { maxRedirects = 0, ...rest } = init;
let current = url;
for (let hop = 0; ; hop++) {
const response = await secureFetchOnce(current, rest);
if (response.status < 300 || response.status >= 400) return response;
const location = response.headers.get('location');
const next = location ? new URL(location, current) : null;
if (
hop >= maxRedirects ||
!next ||
(next.protocol !== 'https:' && next.protocol !== 'http:')
) {
throw new HttpError(
400,
`redirects are not allowed (target: ${location ?? 'unknown'})`,
{ legacyCode: 'bad_request' },
);
}
await response.body?.cancel();
current = next.href;
}
}
async function secureFetchOnce(
url: string,
init: Omit<SecureFetchInit, 'maxRedirects'>,
): Promise<Response> {
// Data URIs bypass everything — nothing to resolve, nothing to proxy.
if (url.startsWith('data:')) {
@@ -222,20 +255,10 @@ export async function secureFetch(
const { skipProxy: _skip, ...rest } = init;
// lib.dom and undici fetch types are structurally equivalent here but
// nominally distinct; cast across the boundary.
const response = (await undiciFetch(finalUrl, {
return (await undiciFetch(finalUrl, {
...rest,
headers,
redirect: 'manual',
dispatcher: proxied ? proxyDispatcher : ssrfGuardDispatcher,
} as unknown as Parameters<typeof undiciFetch>[1])) as unknown as Response;
if (response.status >= 300 && response.status < 400) {
throw new HttpError(
400,
`redirects are not allowed (target: ${response.headers.get('location') ?? 'unknown'})`,
{ legacyCode: 'bad_request' },
);
}
return response;
}
+3
View File
@@ -28,6 +28,7 @@ import {
type CardFallbackClients,
} from './cardFallback.js';
import { getTaskbarItems } from './taskbarItems.js';
import { isEmailGateLast } from './verificationOrder.js';
type TaskbarDeps = Parameters<typeof getTaskbarItems>[1];
@@ -153,6 +154,8 @@ export async function buildUserDetails(
// client reads it. Only the verification flag ships.
requires_phone_verification: user.requires_phone_verification,
requires_card_verification: user.requires_card_verification,
// Card and phone come before email for this account.
email_gate_last: isEmailGateLast(user),
// A seat reaches nothing until it replaces its admin's password.
requires_password_change: user.requires_password_change,
// The SMS-to-card escape hatch. It ships here because by the time the
+27
View File
@@ -0,0 +1,27 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Whether the account clears card and phone verification before confirming its
* email, and is only sent the confirmation email once it gets there. Decided by
* the signup gate and recorded in the account's metadata.
*/
export const isEmailGateLast = (
user: { metadata?: Record<string, unknown> | null } | null | undefined,
): boolean => user?.metadata?.emailGateLast === true;
@@ -0,0 +1,192 @@
---
title: Watch a Folder for New Files
description: "Learn how to process each file a user adds to a folder with Puter.js, so your app handles new files as soon as they're added."
tags: [fs, events]
order: 47
---
<div class="info">The Events API is in beta. Event shapes, limits, and behavior may change between releases.</div>
Your app can read and write files in the user's Puter account with
[`puter.fs`](/FS/), the Puter file system API. A common feature built on it is
an import folder. The user adds a file to a folder, and your app reads the file
and saves what's in it.
This recipe builds a CSV importer. When the user adds a `.csv` file to the
`inbox` folder, the app reads it and writes a summary file next to it. To know
when a file is added, the app watches the folder with the [Events API](/Events/).
It builds on [Watch for Changes](/recipes/watch-for-changes/), which shows how
to watch a folder to update what your app shows.
Your app processes the files while it's open, so it can use any library, such
as a CSV parser. Files added while your app is closed are processed the next
time it opens. To process them while your app is closed, see
[Run Code in the Background](/recipes/run-code-in-the-background/).
## Watch the Folder
First, create the folder with [`puter.fs.mkdir()`](/FS/mkdir/). Then subscribe
to it with [`puter.events.onLocal()`](/Events/onLocal/), which calls a function
each time something in the folder changes. It takes a subject, which is a
string that says what to watch, and the function to call. The subject
`fs:inbox/*.csv:add` matches new `.csv` files in `inbox`:
```js
async function summarize(path) {
const blob = await puter.fs.read(path);
const text = await blob.text();
const name = path.split('/').pop().replace(/\.csv$/, '');
await puter.fs.write(`inbox/${name}.summary.json`, JSON.stringify({
rows: text.trim().split('\n').length - 1,
at: Date.now(),
}));
}
await puter.fs.mkdir('inbox', { createMissingParents: true });
await puter.events.onLocal('fs:inbox/*.csv:add', ({ event }) => summarize(event.path));
```
The function is called with `{ event }`, and `event.path` is the full path of
the new file. The `summarize()` function reads the file with
[`puter.fs.read()`](/FS/read/) and writes a summary with
[`puter.fs.write()`](/FS/write/). In your app, this is where you'd parse the
rows and save them. Add a CSV file to `inbox`, and its summary appears in the
same folder.
Each app has its own folder in the user's account, and a relative path such as
`inbox` is inside it. A relative path works the same way in a subject as in
[`puter.fs`](/FS/). If the folder already exists,
[`mkdir()`](/FS/mkdir/) returns it, so the code works every time the app opens.
In the subject, `*` matches any file name, and the `:add` suffix limits the
subscription to new files. The summary ends in `.json`, so the subject doesn't
match it, and writing it doesn't call the function again. All the patterns and
suffixes are listed on [Subjects and events](/Events/subjects/).
On a website, the user has to be signed in. If nobody is,
[`onLocal()`](/Events/onLocal/) opens the Puter sign-in window.
## Process Files Moved Into the Folder
Uploads and copies arrive as `add`. A file moved in from another folder, for
example by dragging it on the Puter desktop, arrives as a `move`, so the `:add`
subject misses it. To process both, leave the suffix off and check `event.op`:
```js
await puter.events.onLocal('fs:inbox/*.csv', ({ event }) => {
const arrived = event.op === 'add' || (event.op === 'move' && !event.from);
if (!arrived) return;
return summarize(event.path);
});
```
A `move` has a `from` field when the file was already inside the watched
folder, for example when it's renamed inside `inbox`. The function skips those,
along with `write` and `remove` events. A file moved in from another folder has
no `from`.
This depends on `inbox` existing when you subscribe. A subscription on a folder
that doesn't exist yet watches from the nearest folder above it, so a file moved
in from a folder next to `inbox` would have a `from`. That's why the code
creates `inbox` first.
## Catch Up on Missed Files
Files added while your app is closed send no events. Sometimes an event also
can't be delivered, for example when the connection drops while a laptop
sleeps. The function then gets a gap marker in its place. A gap marker is an
event with `op: 'gap'` and no `path`, so it doesn't say which files were added.
[Watch for Changes](/recipes/watch-for-changes/#handle-gaps) has more on gaps.
To catch up, list the folder with [`puter.fs.readdir()`](/FS/readdir/) and
process every CSV file that has no summary yet. Run it once after subscribing,
and again on each gap:
```js
async function catchUp() {
const items = await puter.fs.readdir('inbox');
const done = new Set(items
.filter(i => i.name.endsWith('.summary.json'))
.map(i => i.name.replace(/\.summary\.json$/, '')));
for (const item of items) {
if (!item.name.endsWith('.csv')) continue;
if (done.has(item.name.replace(/\.csv$/, ''))) continue;
await summarize(item.path);
}
}
function handler({ event }) {
if (event.op === 'gap') return catchUp();
const arrived = event.op === 'add' || (event.op === 'move' && !event.from);
if (!arrived) return;
return summarize(event.path);
}
const sub = await puter.events.onLocal('fs:inbox/*.csv', handler);
await catchUp();
```
The summary files record which CSV files are already processed, so the app
doesn't need to store that anywhere else.
The code subscribes before the first `catchUp()`, so a file added during the
listing still reaches the function. That file can be processed twice, so make
processing safe to repeat. Here the second summary overwrites the first. In
your app, save the rows under a key made from the file name, so processing the
file again replaces its rows instead of adding a second copy.
## Stop Watching
To stop, call [`off()`](/Events/off/) on the subscription, for example when the
user leaves the screen that shows the inbox:
```js
// Later, when the user leaves this screen:
await sub.off();
```
Closing the tab ends the subscription on its own.
## Full Code
```js
async function summarize(path) {
const blob = await puter.fs.read(path);
const text = await blob.text();
const name = path.split('/').pop().replace(/\.csv$/, '');
await puter.fs.write(`inbox/${name}.summary.json`, JSON.stringify({
rows: text.trim().split('\n').length - 1,
at: Date.now(),
}));
}
async function catchUp() {
const items = await puter.fs.readdir('inbox');
const done = new Set(items
.filter(i => i.name.endsWith('.summary.json'))
.map(i => i.name.replace(/\.summary\.json$/, '')));
for (const item of items) {
if (!item.name.endsWith('.csv')) continue;
if (done.has(item.name.replace(/\.csv$/, ''))) continue;
await summarize(item.path);
}
}
function handler({ event }) {
if (event.op === 'gap') return catchUp();
const arrived = event.op === 'add' || (event.op === 'move' && !event.from);
if (!arrived) return;
return summarize(event.path);
}
await puter.fs.mkdir('inbox', { createMissingParents: true });
const sub = await puter.events.onLocal('fs:inbox/*.csv', handler);
await catchUp();
// Later, when the user leaves this screen:
await sub.off();
```
@@ -73,6 +73,8 @@ const SEND_REASON_MESSAGES = {
"We couldn't verify your device. Please email [email protected] for assistance.",
phone_virtual_number:
"This number can't be used for verification. Please use a mobile number, or verify with a card instead.",
phone_signals_missing:
"We couldn't verify this browser. Please verify with a card instead, or allow this page in your tracker blocker and try again.",
};
// Seconds the "Re-send code" link stays disabled after a send.
+36 -21
View File
@@ -596,26 +596,31 @@ function UIWindowSignup(options) {
show_email_dialog
) {
$(el_window).close();
// Verification gates run in order: email →
// phone (SMS) → card, matching the server-side
// order in assertVerifiedAccount.
if (show_email_dialog) {
email_verified =
await UIWindowEmailConfirmationRequired({
stay_on_top: true,
has_head: true,
reload_on_success:
options.reload_on_success,
window_options:
options.window_options ?? {},
});
}
// Verification gates run in the server's order
// (see assertVerifiedAccount): email → phone
// (SMS) → card, or card → phone → email when the
// account clears email last.
const emailGate = async () => {
if (show_email_dialog) {
email_verified =
await UIWindowEmailConfirmationRequired({
stay_on_top: true,
has_head: true,
reload_on_success:
options.reload_on_success,
window_options:
options.window_options ?? {},
});
}
};
// A user the SMS path keeps failing may take the
// card fallback instead; that resolves 'card' and
// clears the card gate along with the phone gate
// (see UIWindowPhoneVerificationRequired).
let card_gate_cleared_by_fallback = false;
if (data.user?.requires_phone_verification) {
const phoneGate = async () => {
if (!data.user?.requires_phone_verification)
return;
let phone_ok = false;
do {
phone_ok =
@@ -632,12 +637,13 @@ function UIWindowSignup(options) {
} while (!phone_ok);
card_gate_cleared_by_fallback =
phone_ok === 'card';
}
// Card verification is the last gate.
if (
data.user?.requires_card_verification &&
!card_gate_cleared_by_fallback
) {
};
const cardGate = async () => {
if (
!data.user?.requires_card_verification ||
card_gate_cleared_by_fallback
)
return;
let card_ok = false;
do {
card_ok =
@@ -649,6 +655,15 @@ function UIWindowSignup(options) {
options.window_options ?? {},
});
} while (!card_ok);
};
if (data.user?.email_gate_last) {
await cardGate();
await phoneGate();
await emailGate();
} else {
await emailGate();
await phoneGate();
await cardGate();
}
}
+142 -114
View File
@@ -2041,61 +2041,75 @@ window.initgui = async function (options) {
}
if (whoami) {
// Verification gates run in order: email → phone (SMS) → card,
// matching the server-side order in assertVerifiedAccount.
if (whoami.requires_email_confirmation) {
let is_verified;
do {
is_verified = await UIWindowEmailConfirmationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
window_options: {
is_draggable: false,
},
});
} while (!is_verified);
}
// is phone verification required? (hard gate for low-rep signups)
// A user the SMS path keeps failing may be offered the card
// fallback instead; that resolves 'card' and clears the card gate
// too (see UIWindowPhoneVerificationRequired).
// Verification gates run in the server's order (see
// assertVerifiedAccount): email → phone (SMS) → card, or card →
// phone → email when the account clears email last.
let card_gate_cleared_by_fallback = false;
if (whoami.requires_phone_verification) {
let is_verified;
do {
is_verified = await UIWindowPhoneVerificationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
// Already out of SMS attempts (the offer outlives the
// send window, and no send can report it any more).
card_fallback_available:
whoami.card_fallback_available,
window_options: {
is_draggable: false,
},
});
} while (!is_verified);
card_gate_cleared_by_fallback = is_verified === 'card';
}
// Card verification is the last gate: only show it once the email and
// phone (SMS) gates are cleared, since those show up first.
if (
whoami.requires_card_verification &&
!card_gate_cleared_by_fallback
) {
let is_verified;
do {
is_verified = await UIWindowCardVerificationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
window_options: {
is_draggable: false,
},
});
} while (!is_verified);
const emailGate = async () => {
if (whoami.requires_email_confirmation) {
let is_verified;
do {
is_verified = await UIWindowEmailConfirmationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
window_options: {
is_draggable: false,
},
});
} while (!is_verified);
}
};
const phoneGate = async () => {
// is phone verification required? (hard gate for low-rep signups)
// A user the SMS path keeps failing may be offered the card
// fallback instead; that resolves 'card' and clears the card gate
// too (see UIWindowPhoneVerificationRequired).
if (whoami.requires_phone_verification) {
let is_verified;
do {
is_verified = await UIWindowPhoneVerificationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
// Already out of SMS attempts (the offer outlives the
// send window, and no send can report it any more).
card_fallback_available:
whoami.card_fallback_available,
window_options: {
is_draggable: false,
},
});
} while (!is_verified);
card_gate_cleared_by_fallback = is_verified === 'card';
}
};
const cardGate = async () => {
if (
whoami.requires_card_verification &&
!card_gate_cleared_by_fallback
) {
let is_verified;
do {
is_verified = await UIWindowCardVerificationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
window_options: {
is_draggable: false,
},
});
} while (!is_verified);
}
};
if (whoami.email_gate_last) {
await cardGate();
await phoneGate();
await emailGate();
} else {
await emailGate();
await phoneGate();
await cardGate();
}
// Last, matching assertVerifiedAccount's order.
if (whoami.requires_password_change) {
@@ -2319,67 +2333,81 @@ window.initgui = async function (options) {
// verification gates below: the landing's max z-index would cover
// them.
reveal_anonymous_markup(false);
// Verification gates run in order: email → phone (SMS) → card,
// matching the server-side order in assertVerifiedAccount.
if (whoami.requires_email_confirmation) {
let is_verified;
do {
is_verified = await UIWindowEmailConfirmationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
logout_in_footer: true,
window_options: {
is_draggable: false,
cover_page: window.is_embedded,
},
});
} while (!is_verified);
}
// is phone verification required? (hard gate for low-rep signups)
// A user the SMS path keeps failing may be offered the card
// fallback instead; that resolves 'card' and clears the card gate
// too (see UIWindowPhoneVerificationRequired).
// Verification gates run in the server's order (see
// assertVerifiedAccount): email → phone (SMS) → card, or card →
// phone → email when the account clears email last.
let card_gate_cleared_by_fallback = false;
if (whoami.requires_phone_verification) {
let is_verified;
do {
is_verified = await UIWindowPhoneVerificationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
logout_in_footer: true,
// Already out of SMS attempts (the offer outlives the
// send window, and no send can report it any more).
card_fallback_available:
whoami.card_fallback_available,
window_options: {
is_draggable: false,
cover_page: window.is_embedded,
},
});
} while (!is_verified);
card_gate_cleared_by_fallback = is_verified === 'card';
}
// Card verification is the last gate: only show it once the email and
// phone (SMS) gates are cleared, since those show up first.
if (
whoami.requires_card_verification &&
!card_gate_cleared_by_fallback
) {
let is_verified;
do {
is_verified = await UIWindowCardVerificationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
logout_in_footer: true,
window_options: {
is_draggable: false,
cover_page: window.is_embedded,
},
});
} while (!is_verified);
const emailGate = async () => {
if (whoami.requires_email_confirmation) {
let is_verified;
do {
is_verified = await UIWindowEmailConfirmationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
logout_in_footer: true,
window_options: {
is_draggable: false,
cover_page: window.is_embedded,
},
});
} while (!is_verified);
}
};
const phoneGate = async () => {
// is phone verification required? (hard gate for low-rep signups)
// A user the SMS path keeps failing may be offered the card
// fallback instead; that resolves 'card' and clears the card gate
// too (see UIWindowPhoneVerificationRequired).
if (whoami.requires_phone_verification) {
let is_verified;
do {
is_verified = await UIWindowPhoneVerificationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
logout_in_footer: true,
// Already out of SMS attempts (the offer outlives the
// send window, and no send can report it any more).
card_fallback_available:
whoami.card_fallback_available,
window_options: {
is_draggable: false,
cover_page: window.is_embedded,
},
});
} while (!is_verified);
card_gate_cleared_by_fallback = is_verified === 'card';
}
};
const cardGate = async () => {
if (
whoami.requires_card_verification &&
!card_gate_cleared_by_fallback
) {
let is_verified;
do {
is_verified = await UIWindowCardVerificationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
logout_in_footer: true,
window_options: {
is_draggable: false,
cover_page: window.is_embedded,
},
});
} while (!is_verified);
}
};
if (whoami.email_gate_last) {
await cardGate();
await phoneGate();
await emailGate();
} else {
await emailGate();
await phoneGate();
await cardGate();
}
// Last, matching assertVerifiedAccount's order.
if (whoami.requires_password_change) {
+6 -6
View File
@@ -1056,9 +1056,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz",
"integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==",
"dev": true,
"funding": [
{
@@ -1771,9 +1771,9 @@
"license": "MIT-0"
},
"node_modules/qs": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz",
"integrity": "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
+1 -1
View File
@@ -54,7 +54,7 @@
"@heyputer/kv.js": "^0.2.1",
"open": "^10.2.0",
"path-browserify": "1.0.1",
"postal-mime": "^3.0.1",
"postal-mime": "^4.2.0",
"socket.io-client": "^4.8.4"
}
}