Merge branch 'main' into juancastro/put-1560-file-sharing-deeplink-in-emailnotifications

This commit is contained in:
Juan Castro
2026-08-24 12:21:21 -04:00
37 changed files with 4075 additions and 4045 deletions
+99 -26
View File
@@ -45,6 +45,11 @@ import { HttpError } from '../../core/http';
import type { IConfig, IDynamoConfig } from '../../types';
import { Span } from '../../util/span.js';
import { PuterClient } from '../types';
import {
clampStoredNumber,
isRepairableMarshallError,
repairForMarshall,
} from './marshallRepair.js';
const LOCAL_DYNAMO_MEMORY_PREFIX = ':memory:';
const localDynaliteEndpointPromises = new Map<string, Promise<string>>();
@@ -114,6 +119,49 @@ const sleep = async (ms: number) => {
await new Promise((resolve) => setTimeout(resolve, ms));
};
// One caller writing an out-of-range number usually keeps doing it, so the
// warning is per-target and throttled rather than one line per write.
const REPAIR_WARNING_INTERVAL_MS = 60_000;
const lastRepairWarningAt = new Map<string, number>();
const warnRepaired = (target: string, message: string): void => {
const now = Date.now();
const lastWarnedAt = lastRepairWarningAt.get(target) ?? 0;
if (now - lastWarnedAt < REPAIR_WARNING_INTERVAL_MS) return;
lastRepairWarningAt.set(target, now);
console.warn(
`[ddb] clamped an out-of-range value in ${target}: ${message}`,
);
};
/**
* Send a write, and if the payload was rejected while being encoded for a value
* the store cannot represent, clamp it and send it once more.
*
* Nothing is inspected on the way in: the payload of a successful write is
* never walked, so this costs one `try` on the hot path. The repair only runs
* on the failure, and only retries when it actually changed something —
* otherwise the original error stands.
*/
const sendRepairingValues = async <TPayload, TResult>(
payload: TPayload,
send: (payload: TPayload) => Promise<TResult>,
describe: () => string,
): Promise<TResult> => {
try {
return await send(payload);
} catch (error) {
if (!isRepairableMarshallError(error)) throw error;
const repaired = repairForMarshall(payload);
if (!repaired.changed) throw error;
warnRepaired(describe(), (error as Error).message);
return send(repaired.value as TPayload);
}
};
export class DDBClient extends PuterClient {
#documentClient: DynamoDBDocumentClient | null = null;
#localInitPromise: Promise<void> | null = null;
@@ -171,14 +219,19 @@ export class DDBClient extends PuterClient {
@Span('ddb.put', (table: string) => ({ 'db.table': table }))
async put<T extends Record<string, unknown>>(table: string, item: T) {
const command = new PutCommand({
TableName: table,
Item: item,
ReturnConsumedCapacity: 'TOTAL',
});
const client = await this.#getDocumentClient();
return client.send(command);
return sendRepairingValues(
item,
(itemToWrite) =>
client.send(
new PutCommand({
TableName: table,
Item: itemToWrite,
ReturnConsumedCapacity: 'TOTAL',
}),
),
() => `a put to ${table}`,
);
}
@Span('ddb.batchGet', (params: unknown[]) => ({
@@ -305,11 +358,17 @@ export class DDBClient extends PuterClient {
break;
}
const response = await client.send(
new BatchWriteCommand({
RequestItems: requestItems,
ReturnConsumedCapacity: 'TOTAL',
}),
const response = await sendRepairingValues(
requestItems,
(itemsToWrite) =>
client.send(
new BatchWriteCommand({
RequestItems: itemsToWrite,
ReturnConsumedCapacity: 'TOTAL',
}),
),
() =>
`a batch write to ${Object.keys(requestItems).join(', ')}`,
);
accumulateConsumedCapacity(
response.ConsumedCapacity as
@@ -457,20 +516,27 @@ export class DDBClient extends PuterClient {
!!expressionValues && Object.keys(expressionValues).length > 0;
const hasNames =
!!expressionNames && Object.keys(expressionNames).length > 0;
const command = new UpdateCommand({
TableName: table,
Key: key,
UpdateExpression: expression,
...(hasValues
? { ExpressionAttributeValues: expressionValues }
: {}),
...(hasNames ? { ExpressionAttributeNames: expressionNames } : {}),
ReturnValues: 'ALL_NEW',
ReturnConsumedCapacity: 'TOTAL',
});
const client = await this.#getDocumentClient();
return client.send(command);
return sendRepairingValues(
expressionValues,
(valuesToWrite) =>
client.send(
new UpdateCommand({
TableName: table,
Key: key,
UpdateExpression: expression,
...(hasValues
? { ExpressionAttributeValues: valuesToWrite }
: {}),
...(hasNames
? { ExpressionAttributeNames: expressionNames }
: {}),
ReturnValues: 'ALL_NEW',
ReturnConsumedCapacity: 'TOTAL',
}),
),
() => `an update to ${table}`,
);
}
async createTableIfNotExists(
@@ -572,6 +638,9 @@ export class DDBClient extends PuterClient {
marshallOptions: {
removeUndefinedValues: true,
},
unmarshallOptions: {
wrapNumbers: clampStoredNumber,
},
});
}
@@ -599,6 +668,9 @@ export class DDBClient extends PuterClient {
marshallOptions: {
removeUndefinedValues: true,
},
unmarshallOptions: {
wrapNumbers: clampStoredNumber,
},
});
}
@@ -635,7 +707,8 @@ export class DDBClient extends PuterClient {
);
lastEvaluatedKey = scan.LastEvaluatedKey as
Record<string, unknown> | undefined;
| Record<string, unknown>
| undefined;
const items = scan.Items;
if (!items || items.length === 0) continue;
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest';
import {
clampStoredNumber,
isRepairableMarshallError,
MAX_STORED_NUMBER,
MIN_STORED_NUMBER,
repairForMarshall,
} from './marshallRepair.ts';
describe('isRepairableMarshallError', () => {
it('recognizes the out-of-range and special-value failures', () => {
for (const message of [
'Number 1.6515584833071455e+55 is greater than Number.MAX_SAFE_INTEGER. Use NumberValue from @aws-sdk/lib-dynamodb.',
'Number -1e+55 is lesser than Number.MIN_SAFE_INTEGER. Use NumberValue from @aws-sdk/lib-dynamodb.',
'Special numeric value NaN is not allowed',
'Special numeric value Infinity is not allowed',
]) {
expect(isRepairableMarshallError(new Error(message))).toBe(true);
}
});
it('leaves every other failure alone', () => {
for (const error of [
new Error('Unsupported type passed: [object Symbol].'),
new Error('ValidationException'),
new Error(''),
undefined,
'not an error',
]) {
expect(isRepairableMarshallError(error)).toBe(false);
}
});
});
describe('repairForMarshall', () => {
it('clamps a number past the safe range in both directions', () => {
expect(repairForMarshall(1.6515584833071455e55)).toEqual({
value: MAX_STORED_NUMBER,
changed: true,
});
expect(repairForMarshall(-1e55)).toEqual({
value: MIN_STORED_NUMBER,
changed: true,
});
});
it('clamps infinities and nulls NaN', () => {
expect(repairForMarshall(Infinity).value).toBe(MAX_STORED_NUMBER);
expect(repairForMarshall(-Infinity).value).toBe(MIN_STORED_NUMBER);
expect(repairForMarshall(NaN)).toEqual({ value: null, changed: true });
});
it('repairs numbers nested in objects and arrays', () => {
const repaired = repairForMarshall({
profile: { netWorth: 1e55, level: 29 },
scores: [1, [2, -1e55]],
});
expect(repaired.changed).toBe(true);
expect(repaired.value).toEqual({
profile: { netWorth: MAX_STORED_NUMBER, level: 29 },
scores: [1, [2, MIN_STORED_NUMBER]],
});
});
it('returns the original payload untouched when nothing is out of range', () => {
const payload = {
a: MAX_STORED_NUMBER,
b: [1, 2, { c: 'three' }],
d: null,
};
const repaired = repairForMarshall(payload);
expect(repaired.changed).toBe(false);
expect(repaired.value).toBe(payload);
});
it('does not mutate the payload it repairs', () => {
const nested = { netWorth: 1e55 };
const payload = { nested };
repairForMarshall(payload);
expect(nested.netWorth).toBe(1e55);
});
it('keeps a `__proto__` key as data', () => {
const payload = JSON.parse('{"__proto__":{"big":1e55}}');
const repaired = repairForMarshall(payload) as {
value: Record<string, unknown>;
changed: boolean;
};
expect(repaired.changed).toBe(true);
expect(Object.getPrototypeOf(repaired.value)).toBe(Object.prototype);
expect(
Object.getOwnPropertyDescriptor(repaired.value, '__proto__')?.value,
).toEqual({ big: MAX_STORED_NUMBER });
});
it('leaves values it does not own alone', () => {
const date = new Date(0);
expect(repairForMarshall(date).value).toBe(date);
expect(repairForMarshall('str')).toEqual({
value: 'str',
changed: false,
});
});
});
describe('clampStoredNumber', () => {
it('decodes a number inside the range unchanged', () => {
expect(clampStoredNumber('42')).toBe(42);
expect(clampStoredNumber('-0.5')).toBe(-0.5);
expect(clampStoredNumber(String(MAX_STORED_NUMBER))).toBe(
MAX_STORED_NUMBER,
);
});
it('clamps a stored number past the range instead of decoding a BigInt', () => {
expect(clampStoredNumber('18014398509481982')).toBe(MAX_STORED_NUMBER);
expect(clampStoredNumber('-18014398509481982')).toBe(MIN_STORED_NUMBER);
expect(clampStoredNumber('1e400')).toBe(MAX_STORED_NUMBER);
});
});
@@ -0,0 +1,129 @@
/*
* 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/>.
*/
/**
* A stored number keeps its exact value only within the IEEE-754 safe integer
* range; a magnitude past this cannot round-trip, and the document client
* refuses to marshall one rather than write a value that reads back wrong.
*/
export const MAX_STORED_NUMBER = Number.MAX_SAFE_INTEGER;
export const MIN_STORED_NUMBER = Number.MIN_SAFE_INTEGER;
/**
* Marshalling failures a retry can fix by clamping: an out-of-range number, or
* a special numeric value (`NaN`, `±Infinity`) that has no representation at
* all. Matched on the message because they are plain `Error`s thrown while
* encoding the payload — no error name or code separates them from anything
* else, and they never reach the wire, so there is no response to inspect.
*/
const REPAIRABLE_MARSHALL_ERRORS = [
/is greater than Number\.MAX_SAFE_INTEGER/,
/is lesser than Number\.MIN_SAFE_INTEGER/,
/^Special numeric value /,
];
export const isRepairableMarshallError = (error: unknown): boolean => {
const message = (error as Error | undefined)?.message;
if (typeof message !== 'string') return false;
return REPAIRABLE_MARSHALL_ERRORS.some((pattern) => pattern.test(message));
};
/**
* The same bound, applied to what comes back. A stored number past the safe
* range — left by an older write, or reached by a counter incremented up to it
* — is otherwise decoded as a `BigInt`, which no JSON response can carry. This
* hands back the same kind of value a write accepts, and does less work per
* number than the default decoding it replaces.
*/
export const clampStoredNumber = (stored: string): number => {
const num = Number(stored);
if (num > MAX_STORED_NUMBER) return MAX_STORED_NUMBER;
if (num < MIN_STORED_NUMBER) return MIN_STORED_NUMBER;
return num;
};
export interface MarshallRepair {
value: unknown;
/** False when nothing in the payload was out of range — nothing to retry. */
changed: boolean;
}
const unchanged = (value: unknown): MarshallRepair => ({
value,
changed: false,
});
// Matches the shapes the marshaller walks into as a map: object literals and
// null-prototype objects, but not class instances (Date, Buffer, Set, …),
// which it encodes by their own rules.
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' &&
value !== null &&
(value.constructor === Object || value.constructor === undefined);
const repairNumber = (num: number): MarshallRepair => {
// No representation for these; JSON already turns them into null, so a
// caller that got one this far did not send it as JSON.
if (Number.isNaN(num)) return { value: null, changed: true };
if (num > MAX_STORED_NUMBER)
return { value: MAX_STORED_NUMBER, changed: true };
if (num < MIN_STORED_NUMBER)
return { value: MIN_STORED_NUMBER, changed: true };
return unchanged(num);
};
/**
* Clamp every number a payload holds into the range the store accepts,
* returning the original object untouched when nothing needed it — callers hold
* on to the values they pass (caches, retries), so a repair copies rather than
* mutating in place.
*
* Only run after a marshalling failure. Walking a payload this way costs as
* much as marshalling it, which is exactly why writes are not checked up
* front.
*/
export const repairForMarshall = (value: unknown): MarshallRepair => {
if (typeof value === 'number') return repairNumber(value);
if (Array.isArray(value)) {
let changed = false;
const repairedEntries = value.map((entry) => {
const repaired = repairForMarshall(entry);
changed = changed || repaired.changed;
return repaired.value;
});
return changed ? { value: repairedEntries, changed } : unchanged(value);
}
if (isPlainObject(value)) {
let changed = false;
const repairedEntries = Object.entries(value).map(([key, entry]) => {
const repaired = repairForMarshall(entry);
changed = changed || repaired.changed;
return [key, repaired.value] as const;
});
// `fromEntries` defines each property rather than assigning it, so a
// `__proto__` key in the payload stays data here.
return changed
? { value: Object.fromEntries(repairedEntries), changed }
: unchanged(value);
}
return unchanged(value);
};
+25 -5
View File
@@ -160,6 +160,19 @@ export type EventMap = {
'puter.signup.validate': {
allow: boolean;
email?: string;
/**
* The address in the same canonical form the `email.validate` hook was
* given (`cleanEmail`), so a handler can correlate the two hooks on one
* key. Aliases (`a+tag@outlook.com`, `a.b@icloud.com`) differ from
* `email` here.
*/
clean_email?: string;
/**
* True for a temp (frictionless, no-password) signup. Those carry a
* synthetic `<username>@gmail.com` and never reach email validation, so
* a handler must not draw conclusions from `email`.
*/
is_temp?: boolean;
ip?: string | null;
source?: 'oidc';
req?: unknown;
@@ -264,6 +277,14 @@ export type EventMap = {
publishable_key: string | null;
[key: string]: unknown;
};
// Side-effect-free "is the card gate usable?" probe. The extension stamps
// `enabled` from its own config and does nothing else — no provider call,
// no user state, nothing billed. It exists so the SMS-to-card fallback can
// default on only where a card gate actually works, which means it gets
// asked often and must stay cheap.
'puter.card-verification.status': {
enabled: boolean | null;
};
'puter.card-verification.confirm': {
user_id: number;
user_uid: string;
@@ -597,11 +618,10 @@ 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>;
@@ -30,7 +30,7 @@ describe('PreludeClient', () => {
expect(makeClient().isConfigured()).toBe(false);
});
describe('isCountrySupported (€0.07 cap)', () => {
describe('isCountrySupported (€0.20 cap)', () => {
const client = makeClient('sk_test');
it('allows revenue markets up to the cap (incl. the priciest)', () => {
@@ -40,6 +40,17 @@ describe('PreludeClient', () => {
expect(client.isCountrySupported('us')).toBe(true); // case-insensitive
});
it('allows the markets the old €0.07 cap excluded', () => {
// Raising the cap to €0.20 brought ~89 countries into the phone
// gate. These pin the new range so a future change to the default
// has to say so out loud.
expect(client.isCountrySupported('UA')).toBe(true); // €0.0940
expect(client.isCountrySupported('PH')).toBe(true); // €0.1237
expect(client.isCountrySupported('EG')).toBe(true); // €0.1561
expect(client.isCountrySupported('NG')).toBe(true); // €0.1980
expect(client.isCountrySupported('WS')).toBe(true); // €0.2000, at the cap
});
it('rejects countries above the cap, with no SMS, or unknown', () => {
expect(client.isCountrySupported('PK')).toBe(false); // €0.3548
expect(client.isCountrySupported('ID')).toBe(false); // €0.2430
@@ -63,9 +74,7 @@ describe('PreludeClient', () => {
});
it('createVerification POSTs the phone target + ip signal with bearer auth', async () => {
fetchMock.mockResolvedValue(
okJson({ id: 'vrf_1', status: 'success' }),
);
fetchMock.mockResolvedValue(okJson({ id: 'vrf_1', status: 'success' }));
const client = makeClient('sk_test');
const res = await client.createVerification('+14155550123', {
@@ -81,7 +90,11 @@ describe('PreludeClient', () => {
target: { type: 'phone_number', value: '+14155550123' },
// Defaults to RCS (cheaper); Prelude falls back to SMS. locale is
// hardcoded to en-US so the message text is always English.
options: { code_size: 6, preferred_channel: 'rcs', locale: 'en-US' },
options: {
code_size: 6,
preferred_channel: 'rcs',
locale: 'en-US',
},
signals: { ip: '203.0.113.7' },
});
});
@@ -196,9 +209,9 @@ describe('PreludeClient', () => {
it('throws (does not call fetch) when not configured', async () => {
const client = makeClient();
await expect(
client.createVerification('+14155550123'),
).rejects.toThrow(/not configured/i);
await expect(client.createVerification('+14155550123')).rejects.toThrow(
/not configured/i,
);
expect(fetchMock).not.toHaveBeenCalled();
});
+8 -5
View File
@@ -50,12 +50,15 @@ export type PreludeChannel =
export type PreludeDeliveryChannel = PreludeChannel | 'silent' | 'voice';
/**
* Default per-SMS cost ceiling (EUR). Countries whose Prelude SMS rate exceeds
* this — or that have no SMS channel — are not offered phone verification. The
* cap covers every realistic revenue market (priciest are Germany €0.0598 and
* Saudi Arabia €0.0638) while excluding the expensive, high-fraud long tail.
* Override per-deployment with `config.prelude.maxSmsCostEur`.
* this — or that have no SMS channel — are not offered phone verification.
* Raised from €0.07 to €0.20: the old cap sat only marginally above the
* priciest revenue markets (Germany €0.0598, Saudi Arabia €0.0638), so any rate
* drift silently withdrew the phone gate from a real market — and the abuse
* harness now routes far more legitimate signups into the SMS band, where an
* unavailable gate is a dead end rather than a mild inconvenience. Override
* per-deployment with `config.prelude.maxSmsCostEur`.
*/
const DEFAULT_MAX_SMS_COST_EUR = 0.07;
const DEFAULT_MAX_SMS_COST_EUR = 0.2;
/** Status returned by Prelude when creating/retrying a verification. */
export type PreludeCreateStatus =
@@ -46,6 +46,7 @@ import {
import { PuterServer } from '../../server.js';
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
import { setupTestServer } from '../../testUtil.js';
import { resetCardVerificationStatusCache } from '../../util/cardFallback.js';
import { FS_READ_LIMIT } from '../fs/limits.js';
// ── Test harness ────────────────────────────────────────────────────
@@ -3659,33 +3660,182 @@ describe('AuthController SMS → card fallback', () => {
value: true,
});
it('offers the fallback on send once the attempt threshold is reached', async () => {
const { actor } = await makeUserAndActor({
it('offers the fallback on send only once SMS attempts are exhausted', async () => {
const { user, actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
// No after_attempts → exercises the default threshold of 2.
// No after_attempts → the default is the send route's whole allowance,
// so the offer appears on the last send that limit allows and not
// before: the card path is for a phone that has run out of tries.
await withFallbackConfig({ enabled: true }, async () => {
await withPrelude(stubPrelude(), async () => {
const first = makeRes();
const early = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
first,
early,
);
// First attempt is below the threshold — no offer yet.
expect(first.body).toEqual({});
// One attempt spent, nine still available — no offer.
expect(early.body).toEqual({});
const second = makeRes();
// Stop one short of the allowance: still nothing on offer.
await seedAttempts(user.id, 7);
const penultimate = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
second,
penultimate,
);
expect(second.body).toEqual({
expect(penultimate.body).toEqual({});
// The tenth send is the last one the route will allow, so this
// is the point the user is out of SMS attempts.
const last = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
last,
);
expect(last.body).toEqual({
card_fallback_available: true,
});
});
});
});
// The card gate lives in an extension, so the backend asks for its status
// over the event bus. Stub that one client: `null` stands for no extension
// listening at all, which is what a stock build looks like.
const withCardStatus = async (
enabled: boolean | null,
fn: () => Promise<void>,
): Promise<void> => {
const ctrl = controller as { clients: { event: unknown } };
const real = ctrl.clients.event;
ctrl.clients.event = {
emitAndWait: async (
key: string,
event: Record<string, unknown>,
) => {
if (key === 'puter.card-verification.status') {
if (enabled !== null) event.enabled = enabled;
}
},
emit: () => undefined,
};
resetCardVerificationStatusCache();
try {
await fn();
} finally {
ctrl.clients.event = real;
resetCardVerificationStatusCache();
}
};
it('defaults on when SMS and card verification are both available', async () => {
const { user, actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
// No `phone_verification_card_fallback` at all: the pair it bridges is
// what decides, and here both halves work.
await withFallbackConfig(undefined, async () => {
await withPrelude(stubPrelude(), async () => {
await withCardStatus(true, async () => {
await seedAttempts(user.id, 9);
const res = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
res,
);
expect(res.body).toEqual({
card_fallback_available: true,
});
});
});
});
});
it('stays off by default with no card gate behind it', async () => {
const { user, actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
// Nothing answers the status probe (stock build) — offering a card path
// here could only strand the user, so the default holds it closed.
await withFallbackConfig(undefined, async () => {
await withPrelude(stubPrelude(), async () => {
await withCardStatus(null, async () => {
await seedAttempts(user.id, 9);
const res = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
res,
);
expect(res.body).toEqual({});
});
});
});
});
it('stays off by default when the card gate reports itself disabled', async () => {
const { user, actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
await withFallbackConfig(undefined, async () => {
await withPrelude(stubPrelude(), async () => {
await withCardStatus(false, async () => {
await seedAttempts(user.id, 9);
const res = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
res,
);
expect(res.body).toEqual({});
});
});
});
});
it('honours an explicit opt-out even when both gates are available', async () => {
const { user, actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
await withFallbackConfig({ enabled: false }, async () => {
await withPrelude(stubPrelude(), async () => {
await withCardStatus(true, async () => {
await seedAttempts(user.id, 9);
const res = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
res,
);
expect(res.body).toEqual({});
});
});
});
});
it('clamps after_attempts to the send allowance so it stays reachable', async () => {
const { user, actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
// A threshold above the send limit could never be crossed on its own
// terms — requests past the limit are rejected in middleware and never
// reach the counter — so it is clamped down to the allowance.
await withFallbackConfig(
{ enabled: true, after_attempts: 50 },
async () => {
await withPrelude(stubPrelude(), async () => {
await seedAttempts(user.id, 9);
const res = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
res,
);
expect(res.body).toEqual({
card_fallback_available: true,
});
});
},
);
});
it('never offers the fallback on send when disabled', async () => {
const { actor } = await makeUserAndActor({
requires_phone_verification: 1,
+55 -49
View File
@@ -54,6 +54,17 @@ import {
} from '../../services/auth/OTPUtil.js';
import type { UserRow } from '../../stores/user/UserStore.js';
import { isOwnedEmailConflict } from '../../stores/user/UserStore.js';
import type { CardFallbackDeps } from '../../util/cardFallback.js';
import {
CARD_FALLBACK_OPEN_TTL_SECONDS,
SEND_PHONE_RATE_LIMIT,
SEND_PHONE_RATE_WINDOW_MS,
cardFallbackAfterAttempts,
cardFallbackFlagKey,
isCardFallbackEligible,
isCardFallbackEnabled,
phoneAttemptsKey,
} from '../../util/cardFallback.js';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
import { generate_identifier } from '../../util/identifier.js';
@@ -77,14 +88,6 @@ const FINGERPRINT_MAX_LENGTH = 128;
// crafted request from turning a single grant call into a bulk write.
const MAX_PERMISSIONS_PER_REQUEST = 16;
const DISPATCH_ID_MAX_LENGTH = 128;
// Default SMS send attempts before the card fallback opens.
const DEFAULT_CARD_FALLBACK_ATTEMPTS = 2;
// /send-confirm-phone route rate limit. Also caps the fallback's
// `after_attempts`: requests past the route limit are rejected in middleware
// and never reach the attempt counter, so a higher threshold could never be
// crossed.
const SEND_PHONE_RATE_LIMIT = 10;
const SEND_PHONE_RATE_WINDOW_MS = 60 * 60_000;
// -- Post-login route limits -----------------------------------------
//
@@ -173,9 +176,6 @@ const SESSION_LIMIT = {
window: 60_000,
key: 'user',
} as const;
// Once the threshold is crossed the fallback stays open this long, so the
// user can finish the card flow without racing the attempt counter's expiry.
const CARD_FALLBACK_OPEN_TTL_SECONDS = 24 * 60 * 60;
// How long a failed-SMS-send record stays readable by its error_id — long
// enough to cover the typical support round-trip.
const SMS_SEND_ERROR_TTL_SECONDS = 7 * 24 * 60 * 60;
@@ -862,6 +862,15 @@ export class AuthController extends PuterController {
req.socket?.remoteAddress ||
null) as string | null,
email: body.email,
// The same canonical form `email.validate` was given, so a check
// in the abuse harness can look up the verdict that hook cached
// for this address. Without it an alias (`a+tag@outlook.com`,
// `a.b@icloud.com`) reaches the two hooks under two different keys.
clean_email: cleanEmail(body.email),
// Temp signups carry a synthetic `<username>@gmail.com` and skip
// #validateEmail entirely, so an email check must know not to
// reason about the address at all.
is_temp,
allow: true,
no_temp_user: false,
requires_email_confirmation: false,
@@ -1440,9 +1449,10 @@ export class AuthController extends PuterController {
// -- SMS-to-card fallback -----------------------------------------
//
// Once a user has made enough SMS send attempts in the rate-limit window
// without getting through, they can verify a card instead to clear the
// phone gate. Off unless config enables it.
// Once a user has used up their SMS send attempts for the window without
// getting through, they can verify a card instead to clear the phone gate.
// On wherever both gates work unless config opts out. The rule itself lives
// in ../../util/cardFallback.ts, because /whoami answers the same question.
//
// Two KV keys: a short-lived counter tied to the send rate-limit window
// triggers the fallback, and a longer-lived "open" flag holds eligibility
@@ -1451,30 +1461,11 @@ export class AuthController extends PuterController {
// user is mid-way through the card flow. Every KV failure fails closed
// (fallback unavailable), never open.
private cardFallbackConfig(): { enabled: boolean; afterAttempts: number } {
const cfg = this.config.phone_verification_card_fallback;
const afterAttempts = Math.min(
typeof cfg?.after_attempts === 'number' && cfg.after_attempts > 0
? cfg.after_attempts
: DEFAULT_CARD_FALLBACK_ATTEMPTS,
SEND_PHONE_RATE_LIMIT,
);
return { enabled: Boolean(cfg?.enabled), afterAttempts };
}
private phoneAttemptsKey(userId: number): string {
return `phone-verify-attempts:${userId}`;
}
private cardFallbackFlagKey(userId: number): string {
return `card-fallback-open:${userId}`;
}
// TTL ties the counter to the send rate-limit window, so it resets with it.
private async bumpPhoneAttempts(userId: number): Promise<number> {
try {
const { res } = await this.stores.kv.incr({
key: this.phoneAttemptsKey(userId),
key: phoneAttemptsKey(userId),
pathAndAmountMap: { attempts: 1 },
expireAt:
Math.floor(Date.now() / 1000) +
@@ -1498,16 +1489,15 @@ export class AuthController extends PuterController {
requires_phone_verification?: boolean | number | null;
}): Promise<boolean> {
const attempts = await this.bumpPhoneAttempts(user.id);
const { enabled, afterAttempts } = this.cardFallbackConfig();
const open =
enabled &&
Boolean(user.requires_phone_verification) &&
attempts >= afterAttempts;
attempts >= cardFallbackAfterAttempts(this.config) &&
(await isCardFallbackEnabled(this.config, this.cardFallbackDeps()));
if (open) {
try {
// Plain set, so each eligible attempt refreshes the window.
await this.stores.kv.set({
key: this.cardFallbackFlagKey(user.id),
key: cardFallbackFlagKey(user.id),
value: true,
expireAt:
Math.floor(Date.now() / 1000) +
@@ -1528,17 +1518,33 @@ export class AuthController extends PuterController {
id: number;
requires_phone_verification?: boolean | number | null;
}): Promise<boolean> {
const { enabled } = this.cardFallbackConfig();
if (!enabled || !user.requires_phone_verification) return false;
try {
const { res } = await this.stores.kv.get({
key: this.cardFallbackFlagKey(user.id),
});
return res === true;
} catch (e) {
console.warn('[card-verification] fallback flag read failed:', e);
return false;
}
return isCardFallbackEligible(
this.config,
user,
async (key) => (await this.stores.kv.get({ key })).res,
this.cardFallbackDeps(),
);
}
/**
* The two facts the fallback's default rests on: SMS can only work with a
* provider configured, and the card gate belongs to an extension, so the
* only honest way to ask whether it is on is to ask that extension. Nothing
* is listening on a stock build, which reads as "no card gate".
*/
private cardFallbackDeps(): CardFallbackDeps {
return {
smsConfigured: () => Boolean(this.clients.prelude?.isConfigured()),
probeCardVerification: async () => {
const statusEvent = { enabled: null as boolean | null };
await this.clients.event?.emitAndWait(
'puter.card-verification.status',
statusEvent,
{},
);
return statusEvent.enabled;
},
};
}
@Post('/send-confirm-phone', {
+10 -16
View File
@@ -5,7 +5,6 @@
"description": "Backend/Kernel for Puter",
"main": "exports.ts",
"scripts": {
"test": "npx mocha '**/*.test.js' && node ./tools/test.mjs",
"bench": "vitest bench --config=vitest.bench.config.ts --run"
},
"dependencies": {
@@ -19,26 +18,23 @@
"@aws-sdk/s3-request-presigner": "^3.1028.0",
"@google/genai": "^1.19.0",
"@heyputer/kv.js": "^0.2.1",
"@heyputer/putility": "^1.0.0",
"@mistralai/mistralai": "^1.15.1",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.77.0",
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.219.0",
"@opentelemetry/exporter-trace-otlp-grpc": "^0.219.0",
"@opentelemetry/auto-instrumentations-node": "^0.79.0",
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.221.0",
"@opentelemetry/exporter-trace-otlp-grpc": "^0.221.0",
"@opentelemetry/resources": "^2.8.0",
"@opentelemetry/sdk-metrics": "^2.8.0",
"@opentelemetry/sdk-node": "^0.219.0",
"@opentelemetry/sdk-node": "^0.221.0",
"@opentelemetry/sdk-trace-base": "^2.8.0",
"@opentelemetry/semantic-conventions": "^1.28.0",
"@pagerduty/pdjs": "^2.2.4",
"@smithy/node-http-handler": "^2.5.0",
"@socket.io/redis-streams-adapter": "^0.3.1",
"axios": "^1.15.0",
"bcrypt": "^5.1.1",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.6.0",
"busboy": "^1.6.0",
"chai-as-promised": "^7.1.1",
"clean-css": "^5.3.2",
"compression": "^1.8.1",
"cookie-parser": "^1.4.7",
"dedent": "^1.5.3",
@@ -60,30 +56,28 @@
"nodemailer": "^9.0.1",
"openai": "^6.34.0",
"otpauth": "^9.2.4",
"parse-domain": "^8.2.2",
"pg": "^8.21.0",
"prompt-sync": "^4.2.0",
"replicate": "^1.0.0",
"sharp": "^0.34.5",
"sharp": "^0.35.3",
"socket.io": "^4.8.3",
"svg-captcha": "^1.4.0",
"together-ai": "^0.33.0",
"ua-parser-js": "^1.0.41",
"uglify-js": "^3.17.4",
"undici": "^7.25.0",
"undici": "^7.29.0",
"uuid": "^14.0.0",
"validator": "^13.15.35"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/busboy": "^1.5.4",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/mime-types": "^2.1.4",
"@types/node": "^24.0.0",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.6.1",
"@types/validator": "^13.15.10",
"chai": "^4.3.7",
"nodemon": "^3.1.0",
"pgmock": "^1.0.3",
"typescript": "^5.9.3",
"vite": "^8.0.0",
@@ -50,6 +50,7 @@ const RECOMMENDED_APP_NAMES = [
'checkers',
'backgammon',
'klondike',
'sudoku',
'blockup',
'basketball-tap',
'dev-center',
@@ -285,10 +285,12 @@ describe('SuggestedAppsService hosted-backing guard', () => {
const { userId } = await makeUser();
const sub = uniqueName('builtinlive');
await server.stores.subdomain.create({ userId, subdomain: sub });
// 'markus' is the first built-in opener mapped to `.md`.
await pointBuiltinAt('markus', userId, hostedUrl(sub));
// 'viewer' is the first built-in opener mapped to `.png`.
await pointBuiltinAt('viewer', userId, hostedUrl(sub));
expect((await suggestFor('md')).map((a) => a.name)).toContain('markus');
expect((await suggestFor('png')).map((a) => a.name)).toContain(
'viewer',
);
});
it('drops a built-in-name opener whose hosted backing is gone', async () => {
@@ -156,12 +156,9 @@ function suggestionsForExtension(ext: string): {
if (CODE_EXTS.has(lower)) {
return { names: ['code', 'editor'], isFallback: false };
}
if (lower === 'txt' || lower === '') {
if (lower === 'txt' || lower === 'md' || lower === '') {
return { names: ['editor', 'code'], isFallback: false };
}
if (lower === 'md') {
return { names: ['markus', 'editor', 'code'], isFallback: false };
}
if (IMAGE_EXTS.has(lower)) {
return { names: ['viewer', 'draw'], isFallback: false };
}
+31 -19
View File
@@ -534,6 +534,12 @@ export class OIDCService extends PuterService {
null,
user_agent: req?.headers?.['user-agent'] ?? null,
email,
// See the same field in AuthController: the canonical form
// `email.validate` is given, so the abuse harness can find the
// verdict that hook cached.
clean_email: cleanEmail(email),
// OIDC signups are never temp users.
is_temp: false,
allow: true,
no_temp_user: false,
requires_email_confirmation: false,
@@ -547,25 +553,13 @@ export class OIDCService extends PuterService {
// Request Code so support can look the decision up.
trail_id: undefined as string | undefined,
};
try {
await this.clients.event?.emitAndWait(
'puter.signup.validate',
validateEvent,
{},
);
} catch (e) {
console.warn('[oidc] validate hook failed:', e);
}
if (!validateEvent.allow) {
return {
success: false,
error: validateEvent.message ?? 'Signup blocked',
code: validateEvent.code ?? 'signup_blocked',
requestCode: validateEvent.trail_id,
};
}
// Email validation — mirrors AuthController#validateEmail.
// Email validation — mirrors AuthController#validateEmail, and runs
// BEFORE the signup harness for the same reason it does there: the
// address verdict is an input to the reputation decision. The abuse
// extension's `email.validate` handler caches its Kickbox verdict and
// its `emailQuality` check reads that cache under
// `puter.signup.validate`, so emitting these two in the other order
// silently drops the email signal from every OIDC signup.
if (isBlockedEmail(email, this.config.blockedEmailDomains)) {
return {
success: false,
@@ -595,6 +589,24 @@ export class OIDCService extends PuterService {
};
}
try {
await this.clients.event?.emitAndWait(
'puter.signup.validate',
validateEvent,
{},
);
} catch (e) {
console.warn('[oidc] validate hook failed:', e);
}
if (!validateEvent.allow) {
return {
success: false,
error: validateEvent.message ?? 'Signup blocked',
code: validateEvent.code ?? 'signup_blocked',
requestCode: validateEvent.trail_id,
};
}
const cfg = this.config as {
always_require_phone_verification?: boolean;
always_require_card_verification?: boolean;
@@ -134,6 +134,50 @@ describe('SystemKVStore', () => {
).rejects.toMatchObject({ statusCode: 400 });
});
it('clamps a number too large to store, rather than failing the write', async () => {
await target.set(
{ key: 'huge', value: 1.6515584833071455e55 },
opts,
);
const result = await target.get({ key: 'huge' }, opts);
expect(result.res).toBe(Number.MAX_SAFE_INTEGER);
});
it('clamps numbers nested anywhere inside a value', async () => {
await target.set(
{
key: 'profile',
value: {
username: 'ambastha',
netWorth: 1.6515584833071455e55,
level: 29,
history: [{ delta: -1e55 }],
},
},
opts,
);
const result = await target.get({ key: 'profile' }, opts);
expect(result.res).toEqual({
username: 'ambastha',
netWorth: Number.MAX_SAFE_INTEGER,
level: 29,
history: [{ delta: Number.MIN_SAFE_INTEGER }],
});
});
it('stores a value with no numeric representation as null', async () => {
await target.set(
{ key: 'special', value: { score: NaN, ceiling: Infinity } },
opts,
);
const result = await target.get({ key: 'special' }, opts);
expect(result.res).toEqual({
score: null,
ceiling: Number.MAX_SAFE_INTEGER,
});
});
it('treats a value with an already-elapsed TTL as missing on read', async () => {
const past = Math.floor(Date.now() / 1000) - 10;
await target.set(
@@ -184,6 +228,26 @@ describe('SystemKVStore', () => {
expect(result.res).toEqual(['v1', 'v2', { nested: true }]);
});
it('clamps an out-of-range number in one item without failing the batch', async () => {
await target.batchPut(
{
items: [
{ key: 'bpSafe', value: 7 },
{ key: 'bpHuge', value: { netWorth: 1e55 } },
],
},
opts,
);
const result = await target.get(
{ key: ['bpSafe', 'bpHuge'] },
opts,
);
expect(result.res).toEqual([
7,
{ netWorth: Number.MAX_SAFE_INTEGER },
]);
});
it('is a no-op for an empty items array', async () => {
const result = await target.batchPut({ items: [] }, opts);
expect(result.res).toBe(true);
@@ -749,6 +813,24 @@ describe('SystemKVStore', () => {
});
});
describe('counter overflow', () => {
it('reads back a counter incremented past the safe range as the bound', async () => {
const halfway = Number.MAX_SAFE_INTEGER;
await target.incr(
{ key: 'counter', pathAndAmountMap: { '': halfway } },
opts,
);
const bumped = await target.incr(
{ key: 'counter', pathAndAmountMap: { '': halfway } },
opts,
);
expect(bumped.res).toBe(Number.MAX_SAFE_INTEGER);
const read = await target.get({ key: 'counter' }, opts);
expect(read.res).toBe(Number.MAX_SAFE_INTEGER);
});
});
describe('add', () => {
it('appends a single element to an empty path, creating a new list', async () => {
const result = await target.add(
@@ -802,6 +884,19 @@ describe('SystemKVStore', () => {
});
});
it('clamps an out-of-range number written to a path', async () => {
const result = await target.update(
{
key: 'docHuge',
pathAndValueMap: { 'stats.netWorth': 1e55 },
},
opts,
);
expect(result.res).toMatchObject({
stats: { netWorth: Number.MAX_SAFE_INTEGER },
});
});
it('preserves untouched fields when updating a single path', async () => {
await target.update(
{
@@ -137,6 +137,11 @@ const GLOBAL_APP_KEY = 'os-global';
const SYSTEM_NAMESPACE = `v1:${SYSTEM_ACTOR_UUID}:${GLOBAL_APP_KEY}`;
const MAX_KEY_BYTES = 1024;
const MAX_VALUE_BYTES = 399 * 1024;
// A number anywhere inside a value is bounded too, to the IEEE-754 safe
// integer range — past that it cannot round-trip, so it is clamped to the
// bound as the write is encoded. Enforced there rather than here because
// finding one means walking every value of every write: the whole payload's
// cost again, on the hot path, for something almost nothing sends.
const BATCH_GET_CHUNK = 100;
const PATH_CLEANER_REGEX = /[^A-Za-z0-9_]/g;
// Offset emulation re-scans everything before the requested position, so it
@@ -301,6 +306,11 @@ const assertSafeValueKeys = (value: unknown): void => {
}
};
/**
* Reject a value too big to store, or one holding a key that cannot be walked
* safely. An out-of-range number is not rejected — it is clamped when the write
* is encoded.
*/
const assertValue = (value: unknown): void => {
const size = Buffer.byteLength(JSON.stringify(value ?? null), 'utf8');
if (size > MAX_VALUE_BYTES) {
+26 -13
View File
@@ -784,24 +784,37 @@ interface IConfigOptional {
/**
* Let a user who keeps getting blocked on SMS phone verification fall back
* to credit-card verification, which clears the phone gate (and the card
* gate too, when one is set). Off by default.
* gate too, when one is set).
*
* The fallback opens after `after_attempts` SMS _send_ attempts inside the
* send rate-limit window — successful sends count too, so a user who
* receives codes fine can still choose the card path after that many
* requests. This trades the phone signal for a card signal; it does NOT
* guarantee SMS actually failed. Once open, the fallback stays open for 24
* hours so the user can finish the card flow. Requires a payments extension
* to run the actual card check.
* The fallback opens once the user has made `after_attempts` SMS _send_
* attempts inside the send rate-limit window, which by default means only
* after they have used up the window's entire send allowance — the card
* option is an escape hatch for a phone that isn't working, not a choice
* offered alongside a working SMS flow. Successful sends count too, so this
* trades the phone signal for a card signal; it does NOT guarantee SMS
* actually failed. Once open, the fallback stays open for 24 hours so the
* user can finish the card flow. Requires a payments extension to run the
* actual card check.
*/
phone_verification_card_fallback: {
enabled: boolean;
/**
* Tri-state. Set it and that wins, either way — this is the opt-out.
* Omit it and the fallback is on wherever both gates it bridges
* actually work: an SMS provider is configured _and_ an installed
* extension reports card verification enabled. On a build with no card
* gate behind it the fallback stays off, since taking the offer there
* could only strand the user.
*/
enabled?: boolean;
/**
* SMS send attempts (within the send rate-limit window) before the card
* fallback opens. Defaults to 2 when omitted. Values above the send
* route's rate limit (10/hour) are clamped down to it — requests past
* the route limit never reach the attempt counter, so a higher
* threshold could never be crossed.
* fallback opens. Defaults to the send route's full rate limit
* (10/hour), i.e. the fallback appears only once the user is out of SMS
* attempts. Values above that limit are clamped down to it — requests
* past the route limit never reach the attempt counter, so a higher
* threshold could never be crossed. Lower it (e.g. 2) to reach the card
* path without burning the whole allowance, which is mainly useful for
* QA.
*/
after_attempts?: number;
};
+181
View File
@@ -0,0 +1,181 @@
/*
* 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/>.
*/
/**
* SMS-to-card fallback: the shared rule for whether a phone-gated user has run
* out of SMS attempts and may verify a card instead.
*
* Two consumers need the same answer, so it lives here rather than inside the
* controller: `/send-confirm-phone` (which counts attempts and opens the
* fallback) and `/whoami` (which tells a reloading GUI the fallback is already
* open — the send route can't, because by then every send is rejected by its
* own rate limit before any handler runs).
*/
import type { IConfig } from '../types';
/**
* `/send-confirm-phone` route rate limit: how many verification texts one
* account can ask for per window. This is also the definition of "out of SMS
* attempts" — the fallback opens on the last send this allows, because requests
* past it are rejected in middleware and never reach a handler.
*/
export const SEND_PHONE_RATE_LIMIT = 10;
export const SEND_PHONE_RATE_WINDOW_MS = 60 * 60_000;
/**
* How long the fallback stays open once it opens. Deliberately much longer than
* the attempt counter's window: the counter exists to detect exhaustion, and
* once detected the user needs time to finish the card flow (and to come back
* to it) without racing the counter's expiry.
*/
export const CARD_FALLBACK_OPEN_TTL_SECONDS = 24 * 60 * 60;
/** Attempt counter, TTL-tied to the send rate-limit window. */
export const phoneAttemptsKey = (userId: number): string =>
`phone-verify-attempts:${userId}`;
/** Eligibility flag, the only thing the card endpoints read. */
export const cardFallbackFlagKey = (userId: number): string =>
`card-fallback-open:${userId}`;
/** The user fields the rule reads. */
export interface PhoneGatedUser {
id?: number | null;
requires_phone_verification?: boolean | number | null;
}
/** Reads one system-KV key; resolves whatever was stored (or null/undefined). */
export type ReadKvFlag = (key: string) => Promise<unknown>;
/**
* How many SMS send attempts open the fallback. Defaults to the full send
* allowance — the fallback is meant to appear only once SMS has actually run
* out for this user, not as a competing option alongside a working SMS flow.
*
* A lower `after_attempts` is honoured (it makes the card path reachable
* without burning ten texts, which is what QA wants), and any value above the
* send allowance is clamped down to it: requests past the route limit are
* rejected in middleware and never reach the attempt counter, so a higher
* threshold could never be crossed.
*/
export function cardFallbackAfterAttempts(
config: Pick<IConfig, 'phone_verification_card_fallback'>,
): number {
const cfg = config.phone_verification_card_fallback;
return Math.min(
typeof cfg?.after_attempts === 'number' && cfg.after_attempts > 0
? cfg.after_attempts
: SEND_PHONE_RATE_LIMIT,
SEND_PHONE_RATE_LIMIT,
);
}
/**
* What the fallback needs to know about the rest of the system to decide
* whether it should be on by default.
*/
export interface CardFallbackDeps {
/** Whether SMS verification can work at all — i.e. a provider is set up. */
smsConfigured: () => boolean;
/**
* Asks whichever extension owns card verification whether the card gate is
* on. Resolves null when nothing is listening (no payments extension), so
* "installed but off" and "not installed" stay distinguishable.
*/
probeCardVerification: () => Promise<boolean | null>;
}
/**
* Memoized answer to the card-gate probe. The probe is side-effect free, but it
* is asked on a polled endpoint, so cache it briefly rather than fanning out an
* event per request. Only the _default_ is cached: an explicit `enabled: false`
* short-circuits below without ever consulting this, so the operator's kill
* switch still takes effect immediately.
*/
const CARD_STATUS_TTL_MS = 60_000;
let cardStatusCache: { at: number; enabled: boolean | null } | null = null;
/** Drops the memoized probe answer. For tests, and for a config reload. */
export function resetCardVerificationStatusCache(): void {
cardStatusCache = null;
}
async function cardVerificationEnabled(
deps: CardFallbackDeps,
): Promise<boolean | null> {
const now = Date.now();
if (cardStatusCache && now - cardStatusCache.at < CARD_STATUS_TTL_MS) {
return cardStatusCache.enabled;
}
let enabled: boolean | null = null;
try {
enabled = await deps.probeCardVerification();
} catch (e) {
// Treat a broken probe as "no card gate": offering a card path that may
// not work is worse than not offering one.
console.warn('[card-verification] status probe failed:', e);
}
cardStatusCache = { at: now, enabled };
return enabled;
}
/**
* Whether the SMS-to-card fallback is switched on.
*
* `enabled` is a tri-state. Set explicitly, it wins either way — that is the
* opt-out. Left unset, the fallback follows the pair it bridges: on wherever
* both halves actually work, off otherwise. Without that conjunction the offer
* could show up on a deployment with no card gate behind it, where taking it
* strands the user on a dialog that can only fail.
*/
export async function isCardFallbackEnabled(
config: Pick<IConfig, 'phone_verification_card_fallback'>,
deps: CardFallbackDeps,
): Promise<boolean> {
const configured = config.phone_verification_card_fallback?.enabled;
if (typeof configured === 'boolean') return configured;
if (!deps.smsConfigured()) return false;
return (await cardVerificationEnabled(deps)) === true;
}
/**
* Whether this user may verify a card in place of the phone gate right now.
*
* Reads only the eligibility flag, never the raw attempt counter: the counter
* expires with the send rate-limit window, so deriving eligibility from it
* would revoke the offer mid-flow. The cheap disqualifiers (not phone-gated,
* feature off) come first, so an ordinary `/whoami` never reaches the KV read.
* Every KV failure fails closed.
*/
export async function isCardFallbackEligible(
config: Pick<IConfig, 'phone_verification_card_fallback'>,
user: PhoneGatedUser,
readFlag: ReadKvFlag,
deps: CardFallbackDeps,
): Promise<boolean> {
if (!user.requires_phone_verification) return false;
if (typeof user.id !== 'number') return false;
if (!(await isCardFallbackEnabled(config, deps))) return false;
try {
return (await readFlag(cardFallbackFlagKey(user.id))) === true;
} catch (e) {
console.warn('[card-verification] fallback flag read failed:', e);
return false;
}
}
-7
View File
@@ -7,10 +7,6 @@
·
<a href="https://puter.com/?ref=github.com">Puter.com</a>
·
<a href="https://discord.com/invite/PQcx7Teh8u">Discord</a>
·
<a href="https://reddit.com/r/puter">Reddit</a>
·
<a href="https://twitter.com/HeyPuter">X</a>
</p>
@@ -49,10 +45,7 @@ npm run dev
Connect with the maintainers and community through these channels:
- Bug report or feature request? Please [open an issue](https://github.com/HeyPuter/docs/issues/new).
- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u)
- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter)
- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/)
- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter)
- Security issues? [security@puter.com](mailto:security@puter.com)
- Email maintainers at [hi@puter.com](mailto:hi@puter.com)
-6
View File
@@ -493,18 +493,12 @@ function generateDocsHTML (filePath, rootDir, page, isIndex = false) {
html += '<a href="mailto:hey@puter.com" target="_blank">hey@puter.com</a>';
html += '<span class="bull">&bull;</span>';
html += '<a href="https://discord.gg/PQcx7Teh8u" target="_blank">Discord</a>';
html += '<span class="bull">&bull;</span>';
html += '<a href="https://twitter.com/heyputer" target="_blank">X (Twitter)</a>';
html += '<span class="bull">&bull;</span>';
html += '<a href="https://github.com/HeyPuter" target="_blank">GitHub</a>';
html += '<span class="bull">&bull;</span>';
html += '<a href="https://www.reddit.com/r/puter/" target="_blank">Reddit</a>';
html += '<span class="bull">&bull;</span>';
html += '<a href="/llms.txt" class="skip-insta-load" target="_blank">llms.txt</a>';
html += '</div>';
html += '<p class="copyright-notice">&copy; 2026 Puter Technologies Inc.</p>';
+1 -5
View File
@@ -18,7 +18,6 @@
},
"dependencies": {
"@fontsource/inter": "^5.2.8",
"cssstyle": "^4.6.0",
"esbuild": "0.25.11",
"fs-extra": "^11.2.0",
"highlight.js": "^11.11.1",
@@ -27,12 +26,9 @@
"js-yaml": "^4.1.0",
"jsdom": "^26.1.0",
"marked": "^11.1.1",
"minisearch": "^7.2.0",
"nwsapi": "^2.2.23"
"minisearch": "^7.2.0"
},
"devDependencies": {
"@types/highlight.js": "^9.12.4",
"@types/jquery": "^3.5.33",
"concurrently": "^8.2.2",
"http-server": "^14.1.1",
"nodemon": "^3.1.4"
+2
View File
@@ -27,6 +27,8 @@ The value to add to the key. Defaults to `1` when omitted.
An object where each key is a dot-separated path (for example, `"profile.tags"`) and each value is the value (or values) to add at that path.
Appended values follow the same limits as [`puter.kv.set()`](/KV/set/): **400 KB**, and every number within **±9,007,199,254,740,991** — a larger one is stored clamped to that bound.
## Return value
Returns a `Promise` that resolves to the updated value stored at `key`.
+2
View File
@@ -29,6 +29,8 @@ When `amount` is an object: Increments a property within an object value stored
- Key: the path to the property (e.g., `"user.score"`)
- Value: the amount to increment by
`amount` must be within **±9,007,199,254,740,991** (`Number.MAX_SAFE_INTEGER`); a larger one is applied clamped to that bound. A counter stays exact only while its total is inside the same range — store anything that has to count past it as a string with [`puter.kv.set()`](/KV/set/).
## Return Value
Returns the new value of the key after the increment operation.
+2
View File
@@ -28,6 +28,8 @@ A string containing the name of the key you want to create/update. The maximum a
A string containing the value you want to give the key you are creating/updating. The maximum allowed `value` size is **400 KB**.
Numbers are stored with the precision JavaScript itself keeps: every number in the value — including one nested inside an object or array — must be within **±9,007,199,254,740,991** (`Number.MAX_SAFE_INTEGER`). A number past that is stored clamped to the bound rather than rejected, and `NaN` is stored as `null`. Store an id or a total that has to stay exact past that point as a string.
#### `expireAt` (Number) (optional)
A number containing when the key should expire in timestamp seconds.
+2
View File
@@ -24,6 +24,8 @@ The key to update.
An object where each key is a dot-separated path (for example, `"profile.name"`) and each value is the new value for that path.
Each value follows the same limits as [`puter.kv.set()`](/KV/set/): **400 KB**, and every number within **±9,007,199,254,740,991** — a larger one is stored clamped to that bound.
#### `ttl` (Number) (optional)
Time-to-live for the key, in seconds.
+10
View File
@@ -61,6 +61,16 @@ The OpenAI- and Anthropic-compatible endpoints (`/puterai/openai/v1/*`, `/putera
| Concurrent calls | 30 | 15 | 8 |
| Concurrent `list` | 5 | 3 | 2 |
Sizes are fixed for every account:
| Size | Limit |
| --- | --- |
| Key | 1 KB |
| Value | 400 KB |
| Any number inside a value | ±9,007,199,254,740,991 (2<sup>53</sup>−1) |
A key or value over its size limit is rejected outright. A number over its limit is not: it is stored clamped to the bound, and `NaN` is stored as `null` — the same thing `JSON.stringify()` does with it. This applies to numbers nested anywhere inside an object or array, so a value carrying one still keeps every other field it holds. Anything that has to stay exact past 2<sup>53</sup> — a large id, a running total — should be stored as a string.
### Filesystem
All per minute unless stated:
+3 -15
View File
@@ -11,24 +11,20 @@
"lib": "lib"
},
"devDependencies": {
"@eslint/js": "^9.1.1",
"chai": "^4.3.7",
"chalk": "^4.1.0",
"clean-css": "^5.3.2",
"dotenv": "^16.4.5",
"eslint": "^9.1.1",
"express": "^5.0.0",
"globals": "^15.0.0",
"html-entities": "^2.3.3",
"jsdom": "^29.0.0",
"nodemon": "^3.1.0",
"sinon": "^15.0.1",
"uglify-js": "^3.17.4",
"vitest": "^4.1.5",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.1"
},
"scripts": {
"test": "mocha ./test/**/*.test.js",
"test": "vitest run --config vitest.config.js",
"start:gui": "nodemon --exec \"node dev-server.js\" ",
"build": "node ./build.js",
"check-translations": "node tools/check-translations.js",
@@ -45,15 +41,7 @@
]
},
"dependencies": {
"@opentelemetry/auto-instrumentations-node": "0.77.0",
"@opentelemetry/sdk-node": "0.219.0",
"@prelude.so/js-sdk": "0.12.0",
"@thumbmarkjs/thumbmarkjs": "1.9.1",
"file-type": "21.3.3",
"json-colorizer": "^3.0.1",
"music-metadata": "11.12.3",
"nodemailer": "^9.0.1",
"string-template": "^1.0.0",
"uuid": "^14.0.0"
"@thumbmarkjs/thumbmarkjs": "1.9.1"
}
}
@@ -33,12 +33,18 @@ import {
// The 6-digit code UX mirrors UIWindowEmailConfirmationRequired.js. Used as a
// hard gate for low-reputation signups, so by default it has no close button.
//
// When the server reports `card_fallback_available` on a send (either a
// successful one or a refusal), SMS is not the only way out: the backend has
// opened a card-verification path that clears the phone gate too. This dialog
// surfaces that as an opt-in link rather than leaving the user to retry a send
// that keeps failing. The card dialog can be dismissed straight back here, so
// the choice is reversible either way.
// Once the user is out of SMS send attempts for the window, SMS is not the only
// way out: the backend opens a card-verification path that clears the phone gate
// too, and this dialog surfaces it as an opt-in link rather than leaving the user
// to retry a send that can only be refused. The card dialog can be dismissed
// straight back here, so the choice is reversible either way.
//
// Two things reveal the link, and both are needed. A send response carrying
// `card_fallback_available` covers the attempt that exhausts the allowance,
// which is the send that opens the fallback. `options.card_fallback_available`
// covers every visit after that: further sends are rejected by the route's rate
// limit before the handler runs, so only whoami can still report the offer —
// which matters because it stays valid for 24 hours, well past the send window.
//
// The number field combines a searchable country-code picker with the national
// number. Everything the user types is normalized to E.164 with libphonenumber
@@ -512,10 +518,11 @@ function UIWindowPhoneVerificationRequired(options) {
// ---------- Card escape hatch ----------
//
// Revealed by a send response carrying `card_fallback_available`. Once
// revealed it stays: the backend keeps the eligibility open for hours,
// and a user who came back to try SMS again shouldn't lose the way out
// they were already offered.
// Revealed by a send response carrying `card_fallback_available`, or
// straight away when the caller already knows the fallback is open
// (whoami reports it — see below). Once revealed it stays: the backend
// keeps the eligibility open for hours, and a user who came back to try
// SMS again shouldn't lose the way out they were already offered.
let card_fallback_available = false;
let card_fallback_in_progress = false;
const revealCardFallback = () => {
@@ -524,6 +531,13 @@ function UIWindowPhoneVerificationRequired(options) {
$(el_window).find('.phone-card-fallback').prop('hidden', false);
};
// The fallback only opens once the user is out of SMS send attempts, at
// which point every further send is rejected by the route's rate limit
// before the handler runs — so a send response can no longer advertise
// it. On a reload the offer therefore has to come from whoami, which the
// gate's caller passes in.
if (options.card_fallback_available) revealCardFallback();
// Hand off to the card dialog. This window stays alive behind it (just
// hidden) so a user who backs out — or whose card path turns out to be
// unavailable server-side — lands back on the live gate instead of on
+744
View File
@@ -0,0 +1,744 @@
/*
* 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/>.
*/
const cs = {
"name": "Čeština",
"english_name": "Czech",
"code": "cs",
"dictionary": {
"about": "O aplikaci",
"account": "Účet",
"account_password": "Ověřte heslo účtu",
"add_app_browse_desc": "Procházet centrum aplikací a instalovat potřebné aplikace",
"add_app_ai_desc": "Popište požadovanou aplikaci a umělá inteligence ji vytvoří!",
"add_app_request_c2a": "Řekněte nám, jakou aplikaci byste v Puteru chtěli. Váš požadavek jde přímo našemu týmu.",
"add_app_browse": "Najít aplikaci",
"add_app_request": "Požádat o aplikaci",
"access_granted_to": "Přístup udělen pro",
"add_app": "Přidat aplikaci",
"add_app_ai": "Vytvořit aplikaci pomocí AI",
"all_fields_required": "Všechna pole jsou povinná.",
"allow": "Povolit",
"add_app_request_placeholder": "Aplikace, kterou bych chtěl/a v Puteru…",
"add_app_request_desc": "Řekněte nám, co bychom měli vytvořit jako další",
"add_app_request_failed": "Vaši žádost se nepodařilo odeslat. Zkuste to prosím znovu.",
"app_feedback_error": "Něco se pokazilo. Zkuste to prosím znovu.",
"app_feedback_placeholder": "Co funguje dobře? Co by mohlo být lepší?",
"add_existing_account": "Přidat existující účet",
"add_to_desktop": "Přidat na plochu",
"ai_app_unavailable": "Aplikace AI není k dispozici. Zkuste to znovu později.",
"app_feedback_c2a": "Vaše zpětná vazba bude odeslána přímo vývojáři této aplikace.",
"app_feedback_privacy_note": "Vaše uživatelské jméno a e-mailová adresa budou sdíleny s vývojářem, aby vám mohl odpovědět.",
"add_app_request_sent": "Děkujeme – váš požadavek patří našemu týmu. Pokud váš účet obsahuje e-mail, můžeme na něj navázat.",
"app_feedback_title": "Odeslat zpětnou vazbu",
"app_group_default_name": "Složka",
"app_group_open": "Otevřít",
"app_group_name_aria": "Název složky",
"app_group_rename": "Přejmenovat",
"apply": "Použít",
"ascending": "Vzestupně",
"app_feedback_privacy_note_no_email": "Vaše uživatelské jméno bude sdíleno s vývojářem.",
"app_feedback_rate_limited": "V poslední době jste odeslali spoustu zpětné vazby. Zkuste to znovu později.",
"app_feedback_not_available": "Tato aplikace momentálně nepřijímá zpětnou vazbu.",
"back": "Zpět",
"background": "Pozadí",
"browse": "Procházet",
"browser": "Prohlížeč",
"app_group_tile_aria": "%%, složka %% aplikací",
"cancel": "Zrušit",
"center": "Na střed",
"change": "Změnit",
"app_group_ungroup": "Zrušit seskupení",
"app_group_remove_from_folder": "Odebrat ze složky",
"associated_websites": "Přidružené weby",
"app_feedback_sent": "Zpětná vazba byla odeslána. Děkujeme!",
"auto_arrange": "Automaticky uspořádat",
"browser_version": "Verze prohlížeče",
"change_language": "Změnit jazyk",
"captcha_required": "Dokončete prosím ověření CAPTCHA",
"change_username": "Změnit uživatelské jméno",
"change_password": "Změnit heslo",
"change_always_open_with": "Chcete tento typ souboru vždy otevírat pomocí",
"revalidate_flow_notice": "Až budete pokračovat, budete požádáni o přihlášení pomocí propojeného účtu.",
"reauth_required_message": "Vaše relace byla z důvodu zabezpečení aktualizována – přihlaste se prosím znovu.",
"close": "Zavřít",
"close_all_windows": "Zavřete všechna okna",
"change_email": "Změnit e-mail",
"revalidated": "Znovu ověřeno.",
"color_depth": "Barevná hloubka",
"change_ui_colors": "Změnit barvy uživatelského rozhraní",
"revalidate_with_google": "Znovu ověřte pomocí Googlu",
"color": "Barva",
"confirm": "Potvrdit",
"change_desktop_background": "Změnit pozadí plochy…",
"close_all_windows_confirm": "Opravdu chcete zavřít všechna okna?",
"revalidate_sign_in_popup": "Ve vyskakovacím okně se přihlaste pomocí svého propojeného účtu.",
"confirm_code_generic_try_again": "Zkuste to znovu",
"confirm_code_generic_submit": "Odeslat kód",
"confirm_code_generic_title": "Zadejte potvrzovací kód",
"confirm_code_generic_incorrect": "Nesprávný kód.",
"confirm_code_2fa_submit_btn": "Potvrdit",
"confirm_2fa_recovery": "Uložil jsem své kódy pro obnovení na bezpečné místo",
"confirm_code_2fa_instruction": "Zadejte 6místný kód z aplikace pro ověřování.",
"confirm_continue_as": "Pokračovat jako <strong>{{username}}</strong>?<p>Tento odkaz vás přihlásí k danému účtu. Vše, co vytvoříte – soubory, nahraná videa, chaty – tam bude uloženo a viditelné pro každého, kdo to vlastní. Pokračujte, pouze pokud je účet váš.</p>",
"confirm_new_password": "Potvrďte nové heslo",
"confirm_code_generic_too_many_requests": "Příliš mnoho požadavků. Počkejte prosím několik minut.",
"confirm_2fa_setup": "Přidal jsem kód do své aplikace pro ověřování",
"close_all_windows_and_log_out": "Zavřít všechna okna a odhlásit se",
"confirm_delete_user_title": "Smazat účet?",
"confirm_delete_multiple_items": "Opravdu chcete tyto položky trvale smazat?",
"confirm_code_2fa_title": "Zadejte 2FA kód",
"contact_us": "Kontaktujte nás",
"confirm_open_apps_log_out": "Máte otevřené aplikace. Opravdu se chcete odhlásit?",
"confirm_delete_user": "Opravdu chcete trvale smazat svůj účet? Všechny vaše soubory a data budou odstraněny. Tuto akci nelze vrátit zpět.",
"choose_publishing_option": "Vyberte, jak chcete svůj web publikovat:",
"confirm_delete_single_item": "Chcete tuto položku trvale smazat?",
"confirm_revoke_all_other_sessions": "Zrušit všechny ostatní relace? Zde zůstanete přihlášeni.",
"confirm_your_email_address": "Potvrďte svou e-mailovou adresu",
"confirm_session_revoke": "Opravdu chcete tuto relaci zrušit?",
"contain": "Zobrazit celý",
"cover": "Vyplnit",
"copy": "Kopírovat",
"copy_link": "Kopírovat odkaz",
"create_account": "Vytvořit účet",
"continue": "Pokračovat",
"cpu": "CPU",
"contact_us_verification_required": "Abyste to mohli používat, musíte mít ověřenou e-mailovou adresu.",
"create_desktop_shortcut": "Vytvořit zástupce na ploše",
"copying_file": "Kopírování %%",
"create_desktop_shortcut_s": "Vytvořit zástupce na ploše",
"credits": "Kredity",
"current_password": "Aktuální heslo",
"cut": "Vyjmout",
"close_all": "Zavřít vše",
"created": "Vytvořeno",
"cpu_cores": "CPU jádra",
"change_icon": "Změnit ikonu",
"create_free_account": "Vytvořit bezplatný účet",
"create_shortcut": "Vytvořit zástupce",
"copying": "Kopírování",
"delete": "Vymazat",
"delete_permanently": "Trvale smazat",
"default": "Výchozí",
"delete_account": "Smazat účet",
"desktop": "Plocha",
"desktop_background_fit": "Přizpůsobit",
"descending": "Klesající",
"developers": "Vývojáři",
"disable_2fa": "Zakázat 2FA",
"deleting_file": "Mazání %%",
"directory_depth_limit_exceeded": "Tato složka je vnořena příliš hluboko. Než do ní přidáte další složky, přesuňte ji někam blíže ke kořeni.",
"disable_2fa_confirm": "Opravdu chcete deaktivovat 2FA?",
"dir_published_as_website": "%strong% byl publikován pro:",
"documents": "Dokumenty",
"dont_allow": "Nepovolit",
"date_modified": "Datum změny",
"create_shortcut_s": "Vytvořte zástupce",
"deploy_as_app": "Nasadit jako aplikaci",
"client_information": "Informace o klientovi",
"download": "Stáhnout",
"download_file": "Stáhnout soubor",
"downloading": "Stahování",
"email": "E-mail",
"confirm_download_file_to_desktop": "Opravdu chcete stáhnout %% na plochu?",
"email_change_confirmation_sent": "Na novou e-mailovou adresu byl odeslán potvrzovací e-mail. Zkontrolujte doručenou poštu a dokončete postup podle pokynů.",
"disk_storage": "Diskové úložiště",
"email_invalid": "E-mail je neplatný.",
"disassociate_dir": "Odpojit adresář",
"error_download_failed": "Stažení souboru se nezdařilo",
"disable_2fa_instructions": "Pro deaktivaci 2FA zadejte své heslo.",
"downloading_file": "Stahování %%",
"email_required": "E-mail je povinný.",
"empty_trash": "Vysypat koš",
"emptying_trash": "Vysypávání koše…",
"email_or_username": "E-mail nebo uživatelské jméno",
"enable_2fa": "Povolit 2FA",
"end_hard": "Vynutit ukončení",
"empty_trash_confirmation": "Opravdu chcete trvale smazat položky v koši?",
"end_process_force_confirm": "Opravdu chcete vynutit ukončení tohoto procesu?",
"feedback": "Zpětná vazba",
"favorites": "Oblíbené",
"feedback_c2a": "Pomocí formuláře níže nám pošlete zpětnou vazbu, komentáře a hlášení o chybách.",
"feedback_sent_confirmation": "Děkujeme, že jste nás kontaktovali. Pokud máte ke svému účtu přidružený e-mail, co nejdříve se vám ozveme.",
"fit": "Přizpůsobit",
"folder": "Složka",
"forgot_pass_c2a": "Zapomněli jste heslo?",
"from": "Z",
"general": "Obecné",
"error_uploading_files": "Nahrání souborů se nezdařilo",
"end_soft": "Ukončit běžným způsobem",
"force_quit": "Vynutit ukončení",
"enlarged_qr_code": "Zvětšený QR kód",
"home": "Domů",
"hue": "Odstín",
"image": "Obrázek",
"incorrect_password": "Nesprávné heslo",
"get_a_copy_of_on_puter": "Získejte kopii '%%' na Puter.com!",
"enter_password_to_confirm_delete_user": "Pro potvrzení smazání účtu zadejte své heslo",
"error_unknown_cause": "Došlo k neznámé chybě.",
"error_message_is_missing": "Chybí chybová zpráva.",
"item": "položka",
"items_in_trash_cannot_be_renamed": "Tuto položku nelze přejmenovat, protože je v koši. Chcete-li tuto položku přejmenovat, nejprve ji přetáhněte z koše.",
"language": "Jazyk",
"license": "Licence",
"lightness": "Světlost",
"link_copied": "Odkaz zkopírován",
"loading": "Načítání",
"jpeg_image": "obrázek JPEG",
"get_copy_link": "Získat odkaz ke kopírování",
"keep_both": "Nechte si obojí",
"html_document": "HTML dokument",
"invite_link": "Odkaz na pozvánku",
"keep_in_taskbar": "Ponechat na hlavním panelu",
"hide_all_windows": "Skrýt všechna okna",
"log_in": "Přihlásit se",
"log_out": "Odhlásit se",
"looks_good": "Vypadá to dobře!",
"move": "Přesunout",
"minimize": "Minimalizovat",
"modified": "Upraveno",
"log_into_another_account_anyway": "Přesto se přihlaste k jinému účtu",
"name": "Jméno",
"logging_in_as": "Přihlašujete se jako <strong>{{identity}}</strong>",
"name_cannot_be_empty": "Název nemůže být prázdný.",
"my_websites": "Moje webové stránky",
"reload_app": "Znovu načíst aplikaci",
"name_cannot_contain_period": "Název nesmí obsahovat znak „.“.",
"moving_file": "Přesouvá se %%",
"manage_sessions": "Spravovat relace",
"new": "Nový",
"new_password": "Nové heslo",
"new_folder": "Nová složka",
"no": "Ne",
"name_cannot_contain_double_period": "Název nesmí obsahovat znak „..“.",
"name_cannot_contain_slash": "Název nesmí obsahovat znak '/'.",
"ok": "OK",
"or": "nebo",
"name_too_long": "Název nesmí být delší než %% znaků.",
"new_window": "Nové okno",
"open": "Otevřít",
"open_in_new_tab": "Otevřít na nové kartě",
"open_in_new_window": "Otevřít v novém okně",
"new_email": "Nový e-mail",
"open_desktop": "Otevřít plochu",
"new_username": "Nové uživatelské jméno",
"open_in_ai": "Otevřít v AI",
"os": "Operační systém",
"name_must_be_string": "Název může být pouze řetězec.",
"no_dir_associated_with_site": "K této adrese není přidružen žádný adresář.",
"oss_code_and_content": "Software a obsah s otevřeným zdrojovým kódem",
"no_websites_published": "Dosud jste nepublikovali žádné webové stránky. Začněte kliknutím pravým tlačítkem na složku.",
"os_version": "Verze OS",
"password": "Heslo",
"password_changed": "Heslo bylo změněno.",
"password_recovery_unknown_error": "Došlo k neznámé chybě. Zkuste to znovu později.",
"original_path": "Původní cesta",
"original_name": "Původní název",
"password_required": "Heslo je povinné.",
"open_with": "Otevřít pomocí",
"open_trash": "Otevřít koš",
"path": "Cesta",
"personalization": "Personalizace",
"paste": "Vložit",
"password_strength_error": "Heslo musí mít alespoň 8 znaků a obsahovat alespoň jedno velké písmeno, jedno malé písmeno, jednu číslici a jeden speciální znak.",
"phone_country_label": "Země",
"phone_number_label": "Telefonní číslo",
"phone_send_code": "Odeslat kód",
"password_recovery_rate_limit": "Dosáhli jste našeho rychlostního limitu; počkejte prosím několik minut. Abyste tomu v budoucnu zabránili, vyhněte se opakovanému načítání stránky.",
"password_recovery_token_invalid": "Tento token pro obnovení hesla již není platný.",
"phone_verify_subtitle": "Zašleme vám ověřovací kód, abychom potvrdili, že jste to skutečně vy.",
"passwords_do_not_match": "`Nové heslo` a `Potvrdit nové heslo` se neshodují.",
"phone_enter_valid": "Zadejte prosím platné telefonní číslo.",
"phone_resend_code": "Znovu odeslat kód",
"phone_verify_btn": "Ověřit telefon",
"phone_verify_title": "Ověřte své telefonní číslo",
"paste_into_folder": "Vložit do složky",
"phone_error_reference": "Pokud se to bude opakovat, pošlete e-mail na adresu support@puter.com a uveďte tento kód: {{id}}",
"phone_suggested": "Doporučeno",
"phone_search_countries": "Hledat země",
"phone_change_number": "Změnit číslo",
"phone_no_matches": "Žádné shody",
"phone_could_not_verify": "Kód se nepodařilo ověřit.",
"phone_all_countries": "Všechny země",
"phone_select_country": "Vyberte zemi",
"phone_invalid_code": "Neplatný ověřovací kód.",
"phone_resend_in": "Znovu odeslat za %%",
"phone_could_not_send": "Na toto číslo nelze odeslat kód.",
"picture": "Obrázek",
"pictures": "Obrázky",
"plural_suffix": "",
"preparing": "Příprava...",
"print": "Vytisknout",
"privacy": "Soukromí",
"powered_by_puter_js": "Používá technologii {{link=docs}}Puter.js{{/link}}",
"proceed_with_account_deletion": "Pokračujte ve smazání účtu",
"pick_name_for_website": "Vyberte název pro svůj web:",
"phone_code_sent_whatsapp": "Zadejte 6místný kód odeslaný přes WhatsApp na",
"pick_name_for_worker": "Vyberte jméno pro svého pracovníka:",
"phone_code_sent_to": "Zadejte 6místný kód odeslaný na",
"process_status_running": "Běží",
"process_status_initializing": "Inicializace",
"process_type_ui": "UI",
"process_type_app": "Aplikace",
"properties": "Vlastnosti",
"publish": "Publikovat",
"public": "Veřejné",
"ram": "RAM",
"preparing_for_upload": "Příprava na nahrání...",
"pixel_ratio": "Poměr pixelů",
"proceed_to_login": "Pokračovat k přihlášení",
"publish_as_website": "Publikovat jako web",
"process_type_init": "Inicializace",
"recent": "Nedávné",
"recommended": "Doporučeno",
"refresh": "Obnovit",
"puter_description": "Puter je osobní cloud zaměřený na ochranu soukromí, který uchovává všechny vaše soubory, aplikace a hry na jednom bezpečném místě, které jsou dostupné odkudkoli a kdykoli.",
"rename": "Přejmenovat",
"repeat": "Opakovat",
"replace": "Nahradit",
"refer_friends_social_media_c2a": "Získejte 1 GB úložného prostoru zdarma na Puter.com!",
"release_address_confirmation": "Opravdu chcete uvolnit tuto adresu?",
"reading": "Čtení %strong%",
"writing": "Psaní %strong%",
"Resources": "Zdroje",
"restore": "Obnovit",
"save": "Uložit",
"saturation": "Nasycení",
"reset_colors": "Obnovit barvy",
"replace_all": "Nahradit vše",
"recover_password": "Obnovit heslo",
"publish_as_serverless_worker": "Publikovat jako pracovník",
"remove_from_taskbar": "Odebrat z hlavního panelu",
"save_session": "Uložit relaci",
"save_account_to_get_copy_link": "Chcete-li pokračovat, vytvořte si účet.",
"resend_confirmation_code": "Znovu odeslat potvrzovací kód",
"scan_qr_2fa": "Naskenujte QR kód pomocí aplikace pro ověřování",
"save_account_to_publish": "Chcete-li pokračovat, vytvořte si účet.",
"restart_puter_confirm": "Opravdu chcete restartovat Puter?",
"seconds": "sekundy",
"search": "Hledat",
"save_session_c2a": "Vytvořte si účet pro uložení aktuální relace a vyhněte se ztrátě své práce.",
"security": "Zabezpečení",
"screen_resolution": "Rozlišení obrazovky",
"select": "Vybrat",
"send": "Odeslat",
"sessions": "Relace",
"selected": "vybráno",
"server_information": "Informace o serveru",
"settings": "Nastavení",
"keyboard_shortcuts": "Klávesové zkratky",
"keyboard_shortcuts_action": "Akce",
"session_saved": "Děkujeme za vytvoření účtu. Tato relace byla uložena.",
"send_password_recovery_email": "Odeslat e-mail pro obnovení hesla",
"save_account": "Uložit účet",
"scan_qr_c2a": "Naskenujte níže uvedený kód\npro přihlášení do této relace z jiných zařízení",
"keyboard_shortcuts_shortcut": "Zkratka",
"keyboard_shortcuts_navigation": "Navigace",
"keyboard_shortcuts_general": "Obecné",
"keyboard_shortcuts_search": "Otevřít vyhledávání",
"keyboard_shortcuts_select_all": "Vybrat všechny položky",
"keyboard_shortcuts_intro": "Naučte se nejužitečnější zkratky pro rychlejší navigaci v Puteru.",
"scan_qr_generic": "Naskenujte tento QR kód pomocí telefonu nebo jiného zařízení",
"keyboard_shortcuts_files": "Soubory a schránka",
"keyboard_shortcuts_undo": "Vrátit zpět poslední akci",
"keyboard_shortcuts_open_item": "Otevřít vybranou položku",
"keyboard_shortcuts_close_window": "Zavřít aktivní okno",
"select_color": "Vyberte barvu…",
"keyboard_shortcuts_arrow_navigation": "Procházet nabídky a výběry",
"keyboard_shortcuts_type_to_select": "Zadat název položky a přejít na ni",
"keyboard_shortcuts_open_help": "Otevřít tuto příručku klávesových zkratek",
"keyboard_shortcuts_close_menus": "Zavřít dialogová okna, nabídky a vyskakovací okna",
"share": "Sdílet",
"share_ellipsis": "Sdílet…",
"keyboard_shortcuts_copy": "Kopírovat vybrané položky",
"keyboard_shortcuts_type_to_select_keys": "Zadat písmena nebo čísla",
"keyboard_shortcuts_cut": "Vyjmout vybrané položky",
"keyboard_shortcuts_paste": "Vložit položky",
"share_to": "Sdílet s",
"set_new_password": "Nastavit nové heslo",
"shared": "Sdíleno",
"share_access_level": "Úroveň přístupu",
"keyboard_shortcuts_permanent_delete": "Trvale smazat (po potvrzení)",
"share_access_level_for": "Úroveň přístupu pro {{recipient}}",
"keyboard_shortcuts_delete": "Přesunout vybrané položky do koše",
"shared_by": "Sdíleno uživatelem",
"shared_with_me": "Sdíleno se mnou",
"share_access_write": "Může upravovat",
"share_access_manage": "Může upravovat a sdílet",
"share_access_read": "Lze prohlížet",
"share_who_has_access": "Kdo má přístup",
"share_owner": "Majitel",
"share_done": "Hotovo",
"share_no_one": "Zatím s nikým nesdíleno.",
"share_remove_access_for": "Odebrat přístup pro {{recipient}}",
"share_shared_with": "Sdíleno s {{recipient}}",
"share_failed": "Tuto položku nelze sdílet.",
"share_add_people": "Přidejte lidi pomocí e-mailu nebo uživatelského jména",
"share_nothing_shared": "Zatím s vámi nebylo nic sdíleno.",
"share_awaiting_signup": "Pozván",
"share_invited": "Pozván {{recipient}} – přístup získá po registraci",
"share_cancel_invite": "Zrušit pozvánku",
"share_remove_access": "Odebrat přístup",
"share_remove_from_shared": "Odebrat ze sdílených",
"share_remove": "Odstranit",
"block": "Blok",
"unblock": "Odblokovat",
"share_confirm_remove": "Odebrat uživateli {{recipient}} přístup k této položce?",
"share_invite_cancelled": "Pozvánka do {{recipient}} byla zrušena",
"share_access_removed": "Odebráno {{recipient}}",
"share_cancel_invite_for": "Zrušit pozvánku do {{recipient}}",
"share_access_updated": "Aktualizován přístup pro {{recipient}}",
"share_confirm_cancel_invite": "Zrušit pozvánku odeslanou uživateli {{recipient}}?",
"manage": "Spravovat",
"blocked_senders_note": "Zablokovaní lidé s vámi nemohou sdílet nic nového. To, co již sdíleli, zůstane, dokud to neodstraníte.",
"blocked_add_placeholder": "Uživatelské jméno",
"blocked_senders": "Blokovaní lidé",
"blocked_add": "Zablokovat někoho",
"blocked_all": "Nenechte nikoho sdílet se mnou",
"blocked_all_note": "Odmítne každé nové sdílení, ať už je od kohokoli. Co je s vámi již sdíleno, zůstane.",
"blocked_all_off": "Znovu přijímáte sdílení",
"blocked_all_on": "Nová sdílení jsou nyní odmítána od všech",
"blocked_senders_summary": "Lidé, kteří s vámi nemohou sdílet",
"share_you": "Vy",
"blocked_removed": "Odblokováno {{username}}",
"blocked_none": "Nikoho jste nezablokovali.",
"blocked_failed": "Seznam blokovaných se nepodařilo aktualizovat.",
"shortcut_to": "Zkratka k",
"show_all_windows": "Zobrazit všechna okna",
"share_with": "Sdílet s:",
"share_inherited_via": "přes {{folder}}",
"sign_in": "Přihlásit se",
"size": "Velikost",
"signing_in": "Přihlašování…",
"sign_up": "Vytvořit účet",
"skip": "Přeskočit",
"start": "Spustit",
"something_went_wrong": "Něco se pokazilo. Zkuste to prosím znovu.",
"status": "Stav",
"Storage": "Úložiště",
"blocked_added": "Blokováno {{username}}",
"task_manager": "Správce úloh",
"storage_puter_used": "používá Puter",
"storage_usage": "Využití úložiště",
"sign_in_with_puter": "Přihlásit se pomocí Puteru",
"your_plan": "Váš plán",
"show_hidden": "Zobrazit skryté",
"sort_by": "Seřadit podle",
"taskmgr_header_name": "Jméno",
"taskmgr_header_status": "Stav",
"terms": "Podmínky",
"toolbar.enter_fullscreen": "Přejít na celou obrazovku",
"taskmgr_header_type": "Typ",
"toolbar.github": "GitHub",
"toolbar.refer": "Odkázat",
"toolbar.search": "Hledat",
"toolbar.qrcode": "QR kód",
"transparency": "Průhlednost",
"taking_longer_than_usual": "Trvá to trochu déle než obvykle. Čekejte prosím...",
"trash": "Koš",
"type": "Typ",
"type_confirm_to_delete_account": "Chcete-li svůj účet smazat, zadejte „potvrdit“.",
"two_factor": "Dvoufaktorové ověření",
"toggle_view": "Přepnout zobrazení",
"toolbar.save_account": "Uložit účet",
"two_factor_enabled": "2FA povoleno",
"text_document": "Textový dokument",
"two_factor_disabled": "2FA vypnuto",
"ui_revoke": "Zrušit",
"ui_session_app": "Aplikace",
"ui_session_client": "Klient",
"tos_fineprint": "Registrací vyjadřujete souhlas s {{link=terms}}smluvními podmínkami{{/link}} a {{link=privacy}}zásadami ochrany soukromí{{/link}} společnosti Puter.",
"ui_session_created": "Vytvořeno",
"ui_session_current": "Aktuální",
"ui_session_expires": "Platnost vyprší",
"ui_session_ip": "IP",
"ui_manage_sessions": "Spravovat relace",
"ui_search": "Hledat relace…",
"ui_session_count_one": "1 aktivní relace",
"ui_rename": "Přejmenovat relaci",
"ui_colors": "Barvy uživatelského rozhraní",
"ui_session_kind_access_token": "Přístupový token",
"ui_session_kind_worker": "Pracovník",
"undo": "Vrátit zpět",
"unlimited": "Neomezený",
"ui_session_last_active": "Naposledy aktivní",
"ui_session_count_other": "%% aktivních relací",
"ui_revoke_all_other_sessions": "Odvolat všechny ostatní relace",
"uninstall_sessions_failed": "Aplikace %% byla odinstalována, ale její stávající přihlášení nebylo možné ukončit. Můžete jej odvolat v části Nastavení → Zabezpečení → Spravovat relace.",
"upload": "Nahrát",
"uploading": "Nahrávání",
"ui_session_kind_app": "Relace aplikace",
"ui_toggle_session_children": "Přepnout podřízené relace",
"unzip": "Rozbalit ZIP",
"untar": "Rozbalit TAR",
"ui_session_kind_web": "Relace prohlížeče",
"usage": "Využití",
"username": "Uživatelské jméno",
"username_required": "Uživatelské jméno je povinné.",
"unzipping": "Rozbalení %strong%",
"uploading_file": "Nahrávání %%",
"untarring": "Rozbalování %strong%",
"visibility": "Viditelnost",
"videos": "Videa",
"yes": "Ano",
"versions": "Verze",
"yes_release_it": "Ano, uvolnit",
"uptime": "Doba provozu",
"upload_here": "Nahrát sem",
"zip": "ZIP",
"worker": "Pracovník",
"tar": "Archiv TAR",
"sequencing": "Sekvenování %strong%",
"you_have_been_referred_to_puter_by_a_friend": "Přítel vás doporučil do Puteru!",
"setup2fa_1_step_heading": "Otevřít ověřovací aplikaci",
"zipping": "Komprimování %strong%",
"used_of": "{{used}} využito z {{available}}",
"username_changed": "Uživatelské jméno bylo úspěšně změněno.",
"tarring": "Vytváření archivu TAR %strong%",
"setup2fa_2_step_heading": "Naskenujte QR kód",
"setup2fa_5_confirmation_1": "Uložil jsem své kódy pro obnovení na bezpečné místo",
"setup2fa_5_button": "Povolit 2FA",
"login2fa_otp_title": "Zadejte 2FA kód",
"setup2fa_1_instructions": "Naskenujte QR kód pomocí ověřovací aplikace a poté zadejte 6místný kód, který aplikace vygeneruje.",
"login2fa_otp_instructions": "Zadejte 6místný kód z aplikace pro ověřování.",
"login2fa_recovery_title": "Zadejte kód pro obnovení",
"login2fa_use_recovery_code": "Použijte kód pro obnovení",
"download_as_tar": "Stáhnout jako archiv TAR",
"setup2fa_4_step_heading": "Zkopírujte své kódy pro obnovení",
"setup2fa_3_step_heading": "Zadejte 6místný kód",
"setup2fa_5_confirmation_2": "Jsem připraven povolit 2FA",
"setup2fa_5_step_heading": "Potvrďte nastavení 2FA",
"login2fa_recovery_back": "Zpět",
"Editor": "Editor",
"Owner": "Majitel",
"login2fa_recovery_instructions": "Chcete-li získat přístup k účtu, zadejte jeden ze svých obnovovacích kódů.",
"billing.change_payment_method": "Změnit",
"billing.cancel": "Zrušit",
"billing.download_invoice": "Stáhnout",
"setup2fa_4_instructions": "Tyto kódy pro obnovení jsou jediným způsobem, jak získat přístup k účtu, pokud ztratíte telefon nebo nemůžete použít aplikaci pro ověřování.\n Uložte je na bezpečném místě.",
"billing.payment_method": "Způsob platby",
"login2fa_recovery_placeholder": "XXXXXXXXX",
"Viewer": "Pouze prohlížet",
"billing.refunded": "Vráceno",
"billing.paid": "Zaplaceno",
"This user already has access to this item": "Tento uživatel již má k této položce přístup",
"billing.ok": "OK",
"You can't share with yourself.": "Nemůžete sdílet sami se sebou.",
"billing.confirm_payment_method": "Potvrďte způsob platby",
"billing.payment_method_updated": "Způsob platby aktualizován!",
"Share With…": "Sdílet s…",
"billing.resume_subscription": "Obnovit předplatné",
"People with access": "Lidé s přístupem",
"billing.payment_history": "Historie plateb",
"billing.offering.basic": "Základní",
"billing.offering.professional": "Plus",
"billing.offering.free": "Zdarma",
"business": "Firemní",
"billing.offering.business": "Firemní",
"billing.offering.pro": "Pro",
"pro": "Pro",
"professional": "Plus",
"basic": "Základní",
"free": "Zdarma",
"billing.bandwidth": "Šířka pásma",
"billing.apps_and_games": "Aplikace a hry",
"billing.back": "Zpět",
"billing.you_are_now_subscribed_to": "Nyní jste přihlášeni k odběru úrovně %strong%.",
"billing.subscription_cancelled_description": "Do konce tohoto fakturačního období budete mít stále přístup ke svému předplatnému.",
"billing.subscription_cancellation_confirmation": "Opravdu chcete zrušit předplatné?",
"billing.upgrade_to_pro": "Přejít na %strong%",
"billing.switch_to": "Přepnout na %strong%",
"billing.subscription_cancelled": "Vaše předplatné bylo zrušeno.",
"billing.payment_setup": "Nastavení platby",
"billing.cancel_it": "Zrušit",
"billing.cloud_storage": "Cloudové úložiště",
"billing.ai_access": "Přístup AI",
"billing.you_are_now_subscribed_to_without_tier": "Nyní jste přihlášeni k odběru",
"billing.subscription_setup": "Nastavení předplatného",
"billing.keep_it": "Ponechat",
"billing.subscription_resumed": "Vaše předplatné %strong% bylo obnoveno!",
"billing.upgrade": "Přejít na vyšší tarif",
"billing.upgrade_now": "Přejít na vyšší tarif",
"billing.subscription_check_error": "Při kontrole stavu vašeho předplatného došlo k problému.",
"billing.sub_cancelled_but_valid_until": "Zrušili jste své předplatné a na konci fakturačního období se automaticky přepne na bezplatnou úroveň. Pokud se znovu nepřihlásíte, nebudou vám účtovány žádné poplatky.",
"billing.limited": "Omezený",
"billing.manage": "Spravovat",
"billing.expanded": "Rozšířený",
"billing.accelerated": "Zrychlený",
"billing.email_confirmation_needed": "Váš e-mail nebyl potvrzen. Nyní vám zašleme potvrzovací kód.",
"billing.cancelled_subscription_tier": "Zrušené předplatné (%%)",
"billing.currently_on_free_plan": "Momentálně máte bezplatný tarif.",
"server_timeout": "Serveru trvalo příliš dlouho, než odpověděl. Zkuste to prosím znovu.",
"billing.enjoy_msg": "Užijte si %% cloudového úložiště a další výhody.",
"billing.current_plan_until_end_of_period": "Váš aktuální tarif platí do konce tohoto fakturačního období.",
"billing.download_receipt": "Stáhnout potvrzení",
"billing.current_plan": "Aktuální plán",
"auth_error_generic": "Ověření se nezdařilo. Zkuste to prosím znovu.",
"too_many_attempts": "Příliš mnoho pokusů. Zkuste to znovu později.",
"welcome": "Vítejte",
"signup_blocked_message": "Váš účet se nepodařilo vytvořit.",
"signup_error": "Při registraci došlo k chybě. Zkuste to prosím znovu.",
"your_personal_internet_computer": "Váš osobní internetový počítač",
"contact_support": "Kontaktujte prosím support@puter.com.",
"account_suspended_message": "Tento účet je pozastaven.",
"welcome_title": "Vítejte ve svém osobním internetovém počítači",
"welcome_terms": "Podmínky",
"welcome_developers": "Vývojáři",
"welcome_get_started": "Začněte",
"welcome_privacy": "Soukromí",
"alert_error_title": "Chyba!",
"alert_warning_title": "Varování!",
"alert_info_title": "Informace",
"alert_success_title": "Úspěch!",
"alert_confirm_title": "Jste si jisti?",
"alert_yes": "Ano",
"alert_no": "Ne",
"alert_retry": "Zkuste to znovu",
"signup_confirm_password": "Potvrďte heslo",
"alert_cancel": "Zrušit",
"sign_in_with_google": "Přihlaste se pomocí Google",
"sign_up_with_google": "Zaregistrujte se u Google",
"sign_in_with_apple": "Přihlaste se pomocí Apple",
"sign_up_with_apple": "Zaregistrujte se u Apple",
"welcome_instant_login_title": "Okamžité přihlášení!",
"sign_in_with_microsoft": "Přihlaste se pomocí Microsoftu",
"sign_up_with_microsoft": "Zaregistrujte se u společnosti Microsoft",
"welcome_open_source": "Otevřený software",
"login_password_required": "Je vyžadováno heslo",
"window_title_open": "Otevřít",
"window_title_change_password": "Změnit heslo",
"login_email_username_required": "E-mail nebo uživatelské jméno je povinné",
"oidc_switched_to_login_message": "Byli jste přihlášeni ke stávajícímu účtu.",
"sign_in_with_provider": "Přihlásit se pomocí %%",
"sign_up_with_provider": "Zaregistrujte se pomocí %%",
"welcome_description": "Ukládejte soubory, hrajte hry, najděte úžasné aplikace a mnoho dalšího! Vše na jednom místě, dostupné odkudkoli a kdykoli.",
"contact_support_with_code": "Kontaktujte prosím support@puter.com a uveďte tento kód: {{id}}.",
"window_title_set_new_password": "Nastavit nové heslo",
"window_title_instant_login": "Okamžité přihlášení!",
"window_title_authenticating": "Ověřování...",
"window_title_publish_worker": "Publikovat pracovníka",
"desktop_show_desktop": "Zobrazit plochu",
"window_title_select_font": "Vybrat písmo…",
"window_title_publish_website": "Publikovat webové stránky",
"desktop_show_open_windows": "Zobrazit otevřená okna",
"sign_up_with_email": "Zaregistrujte se pomocí e-mailu",
"window_title_refer_friend": "Doporučte příteli!",
"window_title_session_list": "Seznam relací!",
"desktop_position_right": "Vpravo",
"desktop_position_left": "Vlevo",
"desktop_position_bottom": "Dole",
"desktop_enter_full_screen": "Přejít na celou obrazovku",
"desktop_position": "Pozice",
"item_shortcut": "Zkratka",
"item_shared_by_you": "Tuto položku jste sdíleli s alespoň jedním dalším uživatelem.",
"item_shared_with_you": "Tuto položku s vámi sdílí uživatel.",
"window_click_to_go_up": "Kliknutím přejdete o jeden adresář nahoru.",
"window_title_public": "Veřejné",
"window_title_videos": "Videa",
"window_title_pictures": "Obrázky",
"window_folder_empty": "Tato složka je prázdná",
"item_associated_websites": "Přidružený web",
"window_click_to_go_back": "Kliknutím se vrátíte zpět.",
"desktop_exit_full_screen": "Ukončit režim celé obrazovky",
"manage_your_subdomains": "Spravujte své subdomény",
"no_suitable_apps_found": "Nebyly nalezeny žádné vhodné aplikace",
"window_title_puter": "Puter",
"item_associated_websites_plural": "Přidružené webové stránky",
"window_click_to_go_forward": "Kliknutím přejdete vpřed.",
"perm_fs_resource_access": "přístup k prostředku {{resource_id}} s oprávněním {{access}}.",
"perm_fs_file_access": "používat {{name}} umístěný na {{path}} s oprávněním {{access}}.",
"perm_driver_use": "používat ovladač {{driver}} pro akci {{action}}.",
"set_as_background": "Nastavit jako pozadí plochy",
"perm_folder_access": "{{access}} {{folder}}.",
"perm_thread_post": "příspěvek do vlákna {{thread}}.",
"perm_service_invoke": "použijte {{service}} k vyvolání {{interface}}.",
"perm_folder_pictures": "vaší složce Obrázky",
"open_containing_folder": "Otevřít obsahující složku",
"perm_folder_documents": "vaší složce Dokumenty",
"perm_folder_videos": "vaší složce Videa",
"perm_apps_read": "zobrazit své aplikace",
"perm_email_read": "zobrazit svou e-mailovou adresu",
"perm_apps_write": "spravovat své aplikace",
"perm_folder_desktop": "složku Plocha",
"perm_app_data_subject_data": "Uložená data uživatele {{app}}",
"perm_app_data_subject_files": "Soubory {{app}}",
"perm_app_root_dir_read": "přečíst kořenový adresář jedné z vašich aplikací",
"perm_app_root_dir_write": "číst a zapisovat do kořenového adresáře jedné z vašich aplikací",
"perm_subdomains_write": "spravovat své subdomény",
"perm_subdomains_read": "zobrazit své subdomény",
"perm_app_data_read": "přečíst {{subject}}.",
"perm_dialog_error": "Něco se pokazilo. Zkuste to prosím znovu.",
"perm_app_data_delete": "odstranit položky z {{subject}}.",
"perm_app_data_change": "přečíst a změnit {{subject}}.",
"perm_app_data_store_all": "číst, měnit a mazat {{subject}}.",
"perm_app_data_all": "číst, měnit a mazat vše, co pro vás {{app}} uložil, včetně všech uložených přihlašovacích údajů.",
"approve": "Schválit",
"copy_token_message": "Váš ověřovací token je zobrazen níže. Uchovejte jej v tajnosti – kdokoli s tímto tokenem má přístup k vašemu účtu.",
"perm_dialog_footnote": "Toto můžete kdykoli změnit v Nastavení.",
"auth_token": "Ověřovací token",
"error_user_or_path_not_found": "Uživatel nebo cesta nenalezena.",
"token_copied": "Token zkopírován",
"copy_auth_token": "Kopírovat ověřovací token",
"error_invalid_username": "Neplatné uživatelské jméno.",
"perm_dialog_wants_to": "chce povolení",
"token_label": "Označení",
"token_label_required": "Zadejte štítek pro tento token.",
"copy_token_description": "Zobrazit a zkopírovat ověřovací token",
"create_api_token": "Vytvořit token API",
"token_label_placeholder": "např. Moje CLI, akce GitHubu",
"create_token": "Vytvořit token",
"api_token": "Token API",
"token_expiry_30d": "30 dní",
"token_expiry_7d": "7 dní",
"token_expiry_90d": "90 dní",
"token_expiry_never": "Nikdy",
"token_expiry": "Vypršení platnosti",
"create_token_message": "Vytvořte pojmenovaný token pro použití Puter API ze skriptů, CLI, agentů a integrací. Může dělat cokoli, co můžete prostřednictvím API, ale nemůže změnit vaše heslo nebo e-mail, ani smazat váš účet.",
"api_token_description": "Vytvořte pojmenovaný token pro skripty, CLI a integrace",
"verify_email_to_create_token": "Chcete-li vytvořit token API, ověřte svou e-mailovou adresu.",
"shared_api_token_note": "Aplikace může vaším jménem používat rozhraní Puter API, ale nemůže změnit vaše heslo ani e-mail, ani smazat váš účet. Můžete jej kdykoli odvolat v části Nastavení → Zabezpečení → Spravovat relace.",
"authorization_required": "Vyžaduje se autorizace",
"token_label_external_app": "Externí aplikace",
"token_shown_once_warning": "Zkopírujte tento token nyní – už se nebude zobrazovat. Uchovejte jej v tajnosti; kdokoli s ním může přistupovat k datům vašeho účtu prostřednictvím rozhraní API.",
"shared_api_token": "Omezený token API",
"redirect_destination": "Cíl přesměrování",
"authorization_cancelled_desc": "Zamítli jste žádost o autorizaci.",
"external_site_auth_request": "Aplikace požaduje přístup k vašemu účtu.",
"your_auth_token": "Váš ověřovací token",
"authorization_cancelled_message": "Aplikace nezíská přístup k vašemu účtu. Toto okno můžete bezpečně zavřít.",
"token_manage_hint": "Tento token můžete kdykoli odvolat pod",
"authme_security_warning": "Váš ověřovací token bude sdílen s touto aplikací za účelem dokončení přihlášení.",
"authme_full_token_request": "Aplikace požaduje token s úplným přístupem k vašemu účtu.",
"shared_full_token_item_account": "Změňte své heslo, e-mailovou adresu a dvoufaktorová nastavení",
"shared_full_token_note": "Toto není omezený token API. Pokračujte pouze v případě, že jste s tím začali sami a zcela důvěřujete výše uvedenému cíli. Můžete jej zrušit v Nastavení → Zabezpečení → Spravovat relace.",
"shared_full_token_item_files": "Číst, měnit a mazat všechny své soubory",
"shared_full_token_item_act": "Jednat vaším jménem v Puteru, dokud přístup neodvoláte",
"authme_full_token_confirm_label": "Zadejte {{phrase}} níže a udělte {{host}} úplný přístup k účtu {{username}}.",
"will_be_shared": "Bude sdíleno",
"shared_full_token": "Kompletní relace účtu",
"authme_full_token_title": "Úplný přístup k účtu",
"authorization_cancelled": "Autorizace zrušena",
"authme_full_token_confirm_label_no_user": "Zadejte {{phrase}} níže a poskytněte {{host}} úplný přístup ke svému účtu.",
"authme_full_token_confirm_phrase": "udělit plný přístup",
"remote_backend_signin_failed": "Nelze se přihlásit k {{origin}}. Znovu načtěte tuto stránku a zkuste to znovu, nebo zkontrolujte, zda jste přihlášeni na adrese {{origin}}.",
"authme_bad_redirect_url": "Tento požadavek na autorizaci byl zrušen, protože jeho cíl není platná webová adresa. Nic s ním nebylo sdíleno."
}
};
export default cs;
+100 -98
View File
@@ -1,102 +1,104 @@
/*
* 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/>.
*/
import ar from './ar.js';
import bg from './bg.js';
import bn from './bn.js';
/*
* 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/>.
*/
import ar from './ar.js';
import bg from './bg.js';
import bn from './bn.js';
import br from './br.js';
import cs from './cs.js';
import da from './da.js';
import de from './de.js';
import emoji from './emoji.js';
import en from './en.js';
import es from './es.js';
import fa from './fa.js';
import fi from './fi.js';
import fr from './fr.js';
import he from './he.js';
import hi from './hi.js';
import hu from './hu.js';
import hy from './hy.js';
import id from './id.js';
import it from './it.js';
import ig from './ig.js';
import ja from './ja.js';
import ko from './ko.js';
import ku from './ku.js';
import my from './my.js';
import nb from './nb.js';
import nl from './nl.js';
import nn from './nn.js';
import pl from './pl.js';
import pt from './pt.js';
import ro from './ro.js';
import ru from './ru.js';
import sl from './sl.js';
import sv from './sv.js';
import ta from './ta.js';
import th from './th.js';
import tr from './tr.js';
import ua from './ua.js';
import ur from './ur.js';
import vi from './vi.js';
import zh from './zh.js';
import zhtw from './zhtw.js';
export default {
ar,
bg,
bn,
import de from './de.js';
import emoji from './emoji.js';
import en from './en.js';
import es from './es.js';
import fa from './fa.js';
import fi from './fi.js';
import fr from './fr.js';
import he from './he.js';
import hi from './hi.js';
import hu from './hu.js';
import hy from './hy.js';
import id from './id.js';
import it from './it.js';
import ig from './ig.js';
import ja from './ja.js';
import ko from './ko.js';
import ku from './ku.js';
import my from './my.js';
import nb from './nb.js';
import nl from './nl.js';
import nn from './nn.js';
import pl from './pl.js';
import pt from './pt.js';
import ro from './ro.js';
import ru from './ru.js';
import sl from './sl.js';
import sv from './sv.js';
import ta from './ta.js';
import th from './th.js';
import tr from './tr.js';
import ua from './ua.js';
import ur from './ur.js';
import vi from './vi.js';
import zh from './zh.js';
import zhtw from './zhtw.js';
export default {
ar,
bg,
bn,
br,
cs,
da,
de,
emoji,
en,
es,
fa,
fi,
fr,
he,
hi,
hu,
hy,
id,
ig,
it,
ja,
ko,
ku,
my,
nb,
nl,
nn,
pl,
pt,
ro,
ru,
sl,
sv,
ta,
th,
tr,
ua,
ur,
vi,
zh,
zhtw,
};
de,
emoji,
en,
es,
fa,
fi,
fr,
he,
hi,
hu,
hy,
id,
ig,
it,
ja,
ko,
ku,
my,
nb,
nl,
nn,
pl,
pt,
ro,
ru,
sl,
sv,
ta,
th,
tr,
ua,
ur,
vi,
zh,
zhtw,
};
+8
View File
@@ -1696,6 +1696,10 @@ window.initgui = async function (options) {
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,
},
@@ -1969,6 +1973,10 @@ window.initgui = async function (options) {
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,
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "puter",
"version": "2.6.1",
"version": "2.6.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "puter",
"version": "2.6.1",
"version": "2.6.2",
"license": "Apache-2.0",
"dependencies": {
"@heyputer/kv.js": "^0.1.92",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@heyputer/puter.js",
"version": "2.6.1",
"version": "2.6.2",
"description": "Puter.js gives you auth, cloud storage, database, AI, and more through a single JavaScript library. It is the go-to backend for AI-generated apps.",
"homepage": "https://docs.puter.com",
"main": "src/index.js",
@@ -54,6 +54,6 @@
"@heyputer/kv.js": "^0.2.1",
"open": "^10.2.0",
"path-browserify": "1.0.1",
"socket.io-client": "4.7.2"
"socket.io-client": "^4.8.3"
}
}