mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
11
Commits
26.11
...
NS/PUT-2272
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f35dadb28 | ||
|
|
58456ae87c | ||
|
|
abb70c14a6 | ||
|
|
18fd655645 | ||
|
|
480fa7b85b | ||
|
|
47b54aacb4 | ||
|
|
dd3c0dd485 | ||
|
|
e0689986ed | ||
|
|
0f54588b31 | ||
|
|
0cb9f7707d | ||
|
|
622b782f4e |
No files matched your search
Generated
+21
-28
@@ -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": {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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), {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -76,6 +76,7 @@ export type AccountGateUser = Partial<
|
||||
| 'phone'
|
||||
| 'card_fingerprint'
|
||||
| 'otp_enabled'
|
||||
| 'metadata'
|
||||
>
|
||||
> & { id?: number };
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()}`;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Generated
+6
-6
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user