@jimhoyd/urlcode-auth 0.1.0-alpha.1 → 0.1.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/abuse-http.d.ts +8 -0
- package/dist/abuse-http.js +74 -0
- package/dist/abuse-store.d.ts +5 -0
- package/dist/abuse-store.js +40 -0
- package/dist/abuse.d.ts +27 -0
- package/dist/abuse.js +34 -0
- package/dist/admin-account-operations.d.ts +83 -0
- package/dist/admin-account-operations.js +50 -0
- package/dist/admin-account-store.d.ts +22 -0
- package/dist/admin-account-store.js +185 -0
- package/dist/auth-baseline.d.ts +30 -0
- package/dist/auth-baseline.js +153 -0
- package/dist/auth-core.d.ts +655 -0
- package/dist/auth-core.js +1066 -0
- package/dist/auth-flows.d.ts +30 -0
- package/dist/auth-flows.js +228 -0
- package/dist/auth-signup.d.ts +11 -0
- package/dist/auth-signup.js +145 -0
- package/dist/auth-store.d.ts +81 -0
- package/dist/auth-store.js +1601 -0
- package/dist/auth-templates.d.ts +13 -0
- package/dist/auth-templates.js +74 -0
- package/dist/auth-ui.d.ts +106 -0
- package/dist/auth-ui.js +205 -0
- package/dist/auth.d.ts +49 -0
- package/dist/auth.js +503 -0
- package/dist/backup.d.ts +18 -0
- package/dist/backup.js +121 -0
- package/dist/challenge-ui.d.ts +11 -0
- package/dist/challenge-ui.js +18 -0
- package/dist/challenge.d.ts +21 -0
- package/dist/challenge.js +65 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +137 -0
- package/dist/deployment-check.d.ts +16 -0
- package/dist/deployment-check.js +41 -0
- package/dist/disposable-domain-data.d.ts +1 -0
- package/dist/disposable-domain-data.js +8886 -0
- package/dist/disposable-domains.d.ts +3 -0
- package/dist/disposable-domains.js +17 -0
- package/dist/email-copy.d.ts +114 -0
- package/dist/email-copy.js +58 -0
- package/dist/factor-recovery.d.ts +46 -0
- package/dist/factor-recovery.js +71 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +18 -0
- package/dist/manual-recovery-store.d.ts +25 -0
- package/dist/manual-recovery-store.js +129 -0
- package/dist/manual-recovery.d.ts +87 -0
- package/dist/manual-recovery.js +35 -0
- package/dist/oidc.d.ts +31 -0
- package/dist/oidc.js +54 -0
- package/dist/passkeys.d.ts +24 -0
- package/dist/passkeys.js +29 -0
- package/dist/password-policy.d.ts +7 -0
- package/dist/password-policy.js +72 -0
- package/dist/presentation.d.ts +15 -0
- package/dist/presentation.js +458 -0
- package/dist/presets.d.ts +18 -0
- package/dist/presets.js +17 -0
- package/dist/providers.d.ts +13 -0
- package/dist/providers.js +15 -0
- package/dist/registration.d.ts +45 -0
- package/dist/registration.js +130 -0
- package/dist/scaffold.d.ts +44 -0
- package/dist/scaffold.js +212 -0
- package/dist/second-factor-flows.d.ts +28 -0
- package/dist/second-factor-flows.js +76 -0
- package/dist/senders.d.ts +86 -0
- package/dist/senders.js +153 -0
- package/dist/user-query.d.ts +28 -0
- package/dist/user-query.js +81 -0
- package/package.json +1 -1
package/dist/senders.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createEmailCopy } from "./email-copy.js";
|
|
2
|
+
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { open, realpath, stat, readdir } from 'node:fs/promises';
|
|
5
|
+
import { join, relative, isAbsolute } from 'node:path';
|
|
6
|
+
import { normalizeEmail } from "./auth-core.js";
|
|
7
|
+
const events = { 'registration-attempt': 'Someone tried to create an account with your email address. Your existing account was not changed.', 'new-device': 'A new device signed in to your account.', 'password-changed': 'Your account password changed.', 'email-changed': 'Your account email address changed.' };
|
|
8
|
+
function location(options, development = false) {
|
|
9
|
+
const origin = new URL(options.origin);
|
|
10
|
+
if (origin.origin !== options.origin || origin.username || origin.password || !(origin.protocol === 'https:' || development && origin.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname)))
|
|
11
|
+
throw new Error('Sender requires a canonical HTTPS origin');
|
|
12
|
+
if (!/^\/[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/.test(options.authMount) || options.authMount.length > 256)
|
|
13
|
+
throw new Error('Sender requires an explicit auth mount');
|
|
14
|
+
return { origin: origin.origin, mount: options.authMount, emailCopy: options.emailCopy ?? createEmailCopy() };
|
|
15
|
+
}
|
|
16
|
+
function sender(where, deliver, cleanup = () => { }) {
|
|
17
|
+
let closed = false, active = 0;
|
|
18
|
+
async function send(message, signal) {
|
|
19
|
+
if (closed)
|
|
20
|
+
throw new Error('Sender is closed');
|
|
21
|
+
if (active >= 4)
|
|
22
|
+
throw new Error('Sender is busy');
|
|
23
|
+
signal.throwIfAborted();
|
|
24
|
+
active++;
|
|
25
|
+
const controller = new AbortController(), abort = () => controller.abort(), timer = setTimeout(abort, 5000);
|
|
26
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
27
|
+
let rejectAbort = () => { };
|
|
28
|
+
try {
|
|
29
|
+
await Promise.race([deliver(message, controller.signal), new Promise((_resolve, reject) => {
|
|
30
|
+
rejectAbort = () => reject(new Error('Email delivery aborted'));
|
|
31
|
+
controller.signal.addEventListener('abort', rejectAbort, { once: true });
|
|
32
|
+
if (controller.signal.aborted)
|
|
33
|
+
rejectAbort();
|
|
34
|
+
})]);
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
signal.removeEventListener('abort', abort);
|
|
39
|
+
controller.signal.removeEventListener('abort', rejectAbort);
|
|
40
|
+
active--;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const notice = (email, key, values, signal, locale) => send({ email: normalizeEmail(email), ...where.emailCopy.render(key, values, locale) }, signal);
|
|
44
|
+
const result = Object.assign(async (message) => {
|
|
45
|
+
if (!message || !['verify-email', 'reset-password', 'cancel-deletion', 'invitation', 'verify-email-change', 'cancel-email-change'].includes(message.purpose) || typeof message.token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(message.token))
|
|
46
|
+
throw new Error('Invalid token delivery');
|
|
47
|
+
const paths = { 'verify-email': '/verify', 'reset-password': '/reset', 'cancel-deletion': '/cancel-deletion', invitation: '/register', 'verify-email-change': '/verify-email-change', 'cancel-email-change': '/cancel-email-change' };
|
|
48
|
+
const url = new URL(where.mount + paths[message.purpose], where.origin);
|
|
49
|
+
url.searchParams.set('token', message.token);
|
|
50
|
+
await notice(message.email, message.purpose, { link: url.href }, message.signal, message.locale);
|
|
51
|
+
}, {
|
|
52
|
+
async sendEmailCode(message) {
|
|
53
|
+
if (!message || typeof message.code !== 'string' || !/^\d{6}$/.test(message.code) || typeof message.flowId !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(message.flowId))
|
|
54
|
+
throw new Error('Invalid email code delivery');
|
|
55
|
+
const url = new URL(where.mount + '/email-code', where.origin);
|
|
56
|
+
url.searchParams.set('flowId', message.flowId);
|
|
57
|
+
await notice(message.email, 'sign-in-code', { link: url.href, code: message.code }, message.signal, message.locale);
|
|
58
|
+
},
|
|
59
|
+
async sendSignupCode(message) {
|
|
60
|
+
if (!message || typeof message.code !== 'string' || !/^\d{6}$/.test(message.code))
|
|
61
|
+
throw new Error('Invalid signup code delivery');
|
|
62
|
+
await notice(message.email, 'signup-code', { link: new URL(where.mount + '/signup', where.origin).href, code: message.code }, message.signal, message.locale);
|
|
63
|
+
},
|
|
64
|
+
async sendFactorRecovery(message) {
|
|
65
|
+
if (!message || typeof message.verificationToken !== 'string' || typeof message.cancelToken !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(message.verificationToken) || !/^[A-Za-z0-9_-]{43}$/.test(message.cancelToken))
|
|
66
|
+
throw new Error('Invalid factor recovery delivery');
|
|
67
|
+
const url = new URL(where.mount + '/recover-factor/confirm', where.origin), cancel = new URL(where.mount + '/recover-factor/cancel', where.origin);
|
|
68
|
+
url.searchParams.set('token', message.verificationToken);
|
|
69
|
+
cancel.searchParams.set('token', message.cancelToken);
|
|
70
|
+
await notice(message.email, 'factor-recovery', { link: url.href, cancelLink: cancel.href }, message.signal, message.locale);
|
|
71
|
+
},
|
|
72
|
+
async sendManualRecovery(message) {
|
|
73
|
+
if (!message || typeof message.token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(message.token))
|
|
74
|
+
throw new Error('Invalid manual recovery delivery');
|
|
75
|
+
const email = normalizeEmail(message.email), oldEmail = normalizeEmail(message.oldEmail), url = new URL(where.mount + '/restore-access', where.origin);
|
|
76
|
+
url.searchParams.set('token', message.token);
|
|
77
|
+
await notice(oldEmail, 'manual-recovery-warning', {}, message.signal, message.locale);
|
|
78
|
+
await notice(email, 'manual-recovery', { link: url.href }, message.signal, message.locale);
|
|
79
|
+
},
|
|
80
|
+
async sendAccountAdministration(message) {
|
|
81
|
+
if (!message || !['token', 'notice'].includes(message.kind))
|
|
82
|
+
throw new Error('Invalid account administration delivery');
|
|
83
|
+
if (message.kind === 'token')
|
|
84
|
+
return result({ email: message.email, token: message.token, purpose: message.purpose, signal: message.signal, ...(message.locale ? { locale: message.locale } : {}) });
|
|
85
|
+
if (!['verify-email', 'force-password-reset', 'schedule-deletion', 'cancel-deletion', 'remove-passkey', 'remove-external', 'request-email-change', 'assign-roles', 'resend-verification'].includes(message.action))
|
|
86
|
+
throw new Error('Invalid administration notice');
|
|
87
|
+
await notice(message.email, ('admin-' + message.action), { link: where.origin + where.mount + '/account' }, message.signal, message.locale);
|
|
88
|
+
},
|
|
89
|
+
async notify(message) {
|
|
90
|
+
if (!message || !Object.hasOwn(events, message.event))
|
|
91
|
+
throw new Error('Invalid security notice');
|
|
92
|
+
await notice(message.email, message.event, { link: where.origin + where.mount + '/account' }, message.signal, message.locale);
|
|
93
|
+
},
|
|
94
|
+
close() { if (closed)
|
|
95
|
+
return; closed = true; cleanup(); }
|
|
96
|
+
});
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
export function createSesSender(options) {
|
|
100
|
+
const where = location(options), from = normalizeEmail(options.from);
|
|
101
|
+
if (!/^[a-z]{2}(?:-[a-z]+)+-\d$/.test(options.region))
|
|
102
|
+
throw new Error('Invalid SES region');
|
|
103
|
+
const client = options.transport ? undefined : new SESv2Client({ region: options.region, maxAttempts: 2, ...(options.credentials ? { credentials: options.credentials } : {}) });
|
|
104
|
+
return sender(where, async (message, signal) => { await (options.transport ?? ((command, init) => client.send(command, init)))(new SendEmailCommand({ FromEmailAddress: from, Destination: { ToAddresses: [message.email] }, Content: { Simple: { Subject: { Data: message.subject, Charset: 'UTF-8' }, Body: { Text: { Data: message.text, Charset: 'UTF-8' } } } } }), { abortSignal: signal }); }, () => client?.destroy());
|
|
105
|
+
}
|
|
106
|
+
/** Development only. File output uses exclusive 0600 notices in an existing private directory outside the project. */
|
|
107
|
+
export async function createDevelopmentSender(options) {
|
|
108
|
+
if (options.allowDevelopment !== true)
|
|
109
|
+
throw new Error('Development delivery requires explicit allowDevelopment');
|
|
110
|
+
const where = location(options, true), maximum = options.maxMessages ?? 100;
|
|
111
|
+
if (!Number.isInteger(maximum) || maximum < 1 || maximum > 1000)
|
|
112
|
+
throw new Error('Invalid development notice limit');
|
|
113
|
+
let directory;
|
|
114
|
+
if (options.directory !== undefined) {
|
|
115
|
+
directory = await realpath(options.directory);
|
|
116
|
+
const project = await realpath(options.projectRoot);
|
|
117
|
+
const rel = relative(project, directory);
|
|
118
|
+
if (!rel || !isAbsolute(rel) && rel !== '..' && !rel.startsWith('..' + (process.platform === 'win32' ? '\\' : '/')))
|
|
119
|
+
throw new Error('Development notices must be outside the project');
|
|
120
|
+
const info = await stat(directory);
|
|
121
|
+
if (!info.isDirectory() || process.platform !== 'win32' && (info.mode & 0o077) !== 0)
|
|
122
|
+
throw new Error('Development notice directory must be private');
|
|
123
|
+
}
|
|
124
|
+
else if (options.allowConsoleTokens !== true)
|
|
125
|
+
throw new Error('Console tokens require explicit allowConsoleTokens');
|
|
126
|
+
let count = 0;
|
|
127
|
+
return sender(where, async (message, signal) => {
|
|
128
|
+
signal.throwIfAborted();
|
|
129
|
+
if (count >= maximum)
|
|
130
|
+
throw new Error('Development notice limit reached');
|
|
131
|
+
count++;
|
|
132
|
+
const text = JSON.stringify({ development: true, ...message }) + '\n';
|
|
133
|
+
if (Buffer.byteLength(text) > 4096)
|
|
134
|
+
throw new Error('Development notice too large');
|
|
135
|
+
if (directory) {
|
|
136
|
+
if ((await readdir(directory)).length >= maximum)
|
|
137
|
+
throw new Error('Development notice directory limit reached');
|
|
138
|
+
signal.throwIfAborted();
|
|
139
|
+
const file = await open(join(directory, randomUUID() + '.json'), 'wx', 0o600);
|
|
140
|
+
try {
|
|
141
|
+
await file.writeFile(text);
|
|
142
|
+
await file.sync();
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
await file.close();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
signal.throwIfAborted();
|
|
150
|
+
(options.write ?? console.log)(text);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
export interface UserQuery {
|
|
3
|
+
limit?: number;
|
|
4
|
+
after?: string;
|
|
5
|
+
query?: string;
|
|
6
|
+
status?: 'active' | 'locked' | 'pending-delete';
|
|
7
|
+
role?: string;
|
|
8
|
+
method?: 'password' | 'passkey' | 'oidc';
|
|
9
|
+
verified?: boolean;
|
|
10
|
+
locale?: string;
|
|
11
|
+
createdFrom?: number;
|
|
12
|
+
createdTo?: number;
|
|
13
|
+
lastSeenFrom?: number;
|
|
14
|
+
lastSeenTo?: number;
|
|
15
|
+
sort?: 'id' | 'email' | 'displayName' | 'created' | 'lastSeen';
|
|
16
|
+
direction?: 'asc' | 'desc';
|
|
17
|
+
}
|
|
18
|
+
export interface ValidatedUserQuery extends UserQuery {
|
|
19
|
+
limit: number;
|
|
20
|
+
sort: NonNullable<UserQuery['sort']>;
|
|
21
|
+
direction: 'asc' | 'desc';
|
|
22
|
+
}
|
|
23
|
+
export declare function validateUserQuery(input?: UserQuery): ValidatedUserQuery;
|
|
24
|
+
/** Reads only stored auth metadata. Sort expressions are fixed; all user values are bound parameters. */
|
|
25
|
+
export declare function queryUsers(db: DatabaseSync, raw: UserQuery): {
|
|
26
|
+
users: unknown[];
|
|
27
|
+
next?: string;
|
|
28
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes } from 'node:crypto';
|
|
2
|
+
export function validateUserQuery(input = {}) {
|
|
3
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(key => !['limit', 'after', 'query', 'status', 'role', 'method', 'verified', 'locale', 'createdFrom', 'createdTo', 'lastSeenFrom', 'lastSeenTo', 'sort', 'direction'].includes(key)))
|
|
4
|
+
throw new Error('Invalid user filter');
|
|
5
|
+
const result = { ...input, limit: input.limit ?? 50, sort: input.sort ?? 'id', direction: input.direction ?? 'asc' };
|
|
6
|
+
if (!Number.isInteger(result.limit) || result.limit < 1 || result.limit > 100 || !['id', 'email', 'displayName', 'created', 'lastSeen'].includes(result.sort) || !['asc', 'desc'].includes(result.direction))
|
|
7
|
+
throw new Error('Invalid user page');
|
|
8
|
+
if (result.query !== undefined && (typeof result.query !== 'string' || result.query.length > 254 || /[\x00-\x1f\x7f]/.test(result.query)) || result.role !== undefined && (typeof result.role !== 'string' || !/^[a-z][a-z0-9._-]{0,63}$/.test(result.role)) || result.status !== undefined && !['active', 'locked', 'pending-delete'].includes(result.status) || result.method !== undefined && !['password', 'passkey', 'oidc'].includes(result.method) || result.verified !== undefined && typeof result.verified !== 'boolean')
|
|
9
|
+
throw new Error('Invalid user filter');
|
|
10
|
+
if (result.locale !== undefined && (typeof result.locale !== 'string' || result.locale.length > 64 || !/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(result.locale)))
|
|
11
|
+
throw new Error('Invalid locale filter');
|
|
12
|
+
for (const key of ['createdFrom', 'createdTo', 'lastSeenFrom', 'lastSeenTo'])
|
|
13
|
+
if (result[key] !== undefined && (!Number.isSafeInteger(result[key]) || result[key] < 0))
|
|
14
|
+
throw new Error('Invalid user date');
|
|
15
|
+
if (result.createdFrom !== undefined && result.createdTo !== undefined && result.createdFrom > result.createdTo || result.lastSeenFrom !== undefined && result.lastSeenTo !== undefined && result.lastSeenFrom > result.lastSeenTo)
|
|
16
|
+
throw new Error('Invalid user date range');
|
|
17
|
+
if (result.after !== undefined && (typeof result.after !== 'string' || result.after.length > 1024 || !/^[A-Za-z0-9_-]+$/.test(result.after)))
|
|
18
|
+
throw new Error('Invalid user cursor');
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
function filterHash(input) {
|
|
22
|
+
const { after: _after, limit: _limit, ...filters } = input;
|
|
23
|
+
return createHash('sha256').update(JSON.stringify(Object.fromEntries(Object.entries(filters).sort(([a], [b]) => a.localeCompare(b))))).digest('hex');
|
|
24
|
+
}
|
|
25
|
+
// Process-local keys keep identifiers confidential; restarting the worker invalidates live cursors.
|
|
26
|
+
const cursorKeys = new WeakMap();
|
|
27
|
+
function sortHash(db, id, value) {
|
|
28
|
+
let key = cursorKeys.get(db);
|
|
29
|
+
if (!key) {
|
|
30
|
+
key = randomBytes(32);
|
|
31
|
+
cursorKeys.set(db, key);
|
|
32
|
+
}
|
|
33
|
+
return createHmac('sha256', key).update(JSON.stringify([id, value])).digest('hex');
|
|
34
|
+
}
|
|
35
|
+
/** Reads only stored auth metadata. Sort expressions are fixed; all user values are bound parameters. */
|
|
36
|
+
export function queryUsers(db, raw) {
|
|
37
|
+
const input = validateUserQuery(raw), fingerprint = filterHash(input), where = [], values = [];
|
|
38
|
+
const add = (sql, ...params) => { where.push(sql); values.push(...params); };
|
|
39
|
+
if (input.query)
|
|
40
|
+
add("(instr(lower(email),lower(?))>0 OR instr(lower(display_name),lower(?))>0 OR instr(id,?)>0 OR instr(lower(masked_email),lower(?))>0)", input.query, input.query, input.query, input.query);
|
|
41
|
+
if (input.status)
|
|
42
|
+
add('status=?', input.status);
|
|
43
|
+
if (input.role)
|
|
44
|
+
add("EXISTS(SELECT 1 FROM json_each(json_extract(data,'$.roles')) WHERE value=?)", input.role);
|
|
45
|
+
if (input.verified !== undefined)
|
|
46
|
+
add("COALESCE(json_extract(data,'$.emailVerified'),0)=?", input.verified ? 1 : 0);
|
|
47
|
+
if (input.locale)
|
|
48
|
+
add("lower(COALESCE(json_extract(data,'$.profile.locale'),''))=lower(?)", input.locale);
|
|
49
|
+
if (input.method === 'password')
|
|
50
|
+
add("length(COALESCE(json_extract(data,'$.passwordHash'),''))>0");
|
|
51
|
+
if (input.method === 'passkey')
|
|
52
|
+
add('EXISTS(SELECT 1 FROM auth_passkeys p WHERE p.account_id=observed.id)');
|
|
53
|
+
if (input.method === 'oidc')
|
|
54
|
+
add('EXISTS(SELECT 1 FROM auth_external e WHERE e.account_id=observed.id)');
|
|
55
|
+
for (const [key, column, comparison] of [['createdFrom', 'created', '>='], ['createdTo', 'created', '<='], ['lastSeenFrom', 'last_seen', '>='], ['lastSeenTo', 'last_seen', '<=']])
|
|
56
|
+
if (input[key] !== undefined)
|
|
57
|
+
add(`${column}${comparison}?`, input[key]);
|
|
58
|
+
const column = { id: 'id', email: 'email', displayName: 'display_name', created: 'created', lastSeen: 'last_seen' }[input.sort];
|
|
59
|
+
const observed = `WITH observed AS (SELECT a.id,a.email,a.status,a.data,COALESCE(json_extract(a.data,'$.profile.displayName'),'') AS display_name,COALESCE(json_extract(a.data,'$.created'),0) AS created,MAX(COALESCE(json_extract(a.data,'$.lastSeen'),0),COALESCE((SELECT MAX(d.last_seen) FROM auth_devices d WHERE d.account_id=a.id),0),COALESCE((SELECT MAX(s.last_seen) FROM auth_sessions s WHERE s.account_id=a.id),0)) AS last_seen,substr(a.email,1,1)||'***'||substr(a.email,instr(a.email,'@')) AS masked_email FROM auth_accounts a)`;
|
|
60
|
+
if (input.after) {
|
|
61
|
+
let cursor;
|
|
62
|
+
try {
|
|
63
|
+
cursor = JSON.parse(Buffer.from(input.after, 'base64url').toString('utf8'));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new Error('Invalid user cursor');
|
|
67
|
+
}
|
|
68
|
+
if (!Array.isArray(cursor) || cursor.length !== 3 || cursor[0] !== fingerprint || typeof cursor[1] !== 'string' || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(cursor[1]) || typeof cursor[2] !== 'string' || !/^[a-f0-9]{64}$/.test(cursor[2]))
|
|
69
|
+
throw new Error('Invalid user cursor');
|
|
70
|
+
// Keep private sort values on the server. Live pages restart if their boundary changed.
|
|
71
|
+
const boundary = db.prepare(`${observed} SELECT ${column} AS sort_value FROM observed WHERE id=?`).get(cursor[1]);
|
|
72
|
+
if (!boundary || sortHash(db, cursor[1], boundary.sort_value) !== cursor[2])
|
|
73
|
+
throw new Error('Invalid user cursor; restart the user search');
|
|
74
|
+
const cmp = input.direction === 'asc' ? '>' : '<';
|
|
75
|
+
add(`(${column}${cmp}? OR (${column}=? AND id${cmp}?))`, boundary.sort_value, boundary.sort_value, cursor[1]);
|
|
76
|
+
}
|
|
77
|
+
const direction = input.direction === 'asc' ? 'ASC' : 'DESC';
|
|
78
|
+
const rows = db.prepare(`${observed} SELECT id,data,last_seen AS observed_last_seen,${column} AS sort_value FROM observed ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY ${column} ${direction},id ${direction} LIMIT ?`).all(...values, input.limit + 1);
|
|
79
|
+
const more = rows.length > input.limit, selected = rows.slice(0, input.limit), last = selected.at(-1);
|
|
80
|
+
return { users: selected.map(row => ({ ...JSON.parse(String(row.data)), observedLastSeen: Number(row.observed_last_seen) })), ...(more && last ? { next: Buffer.from(JSON.stringify([fingerprint, last.id, sortHash(db, String(last.id), last.sort_value)])).toString('base64url') } : {}) };
|
|
81
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jimhoyd/urlcode-auth",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Operator-installed authentication extension for URLCode: accounts, sessions, passkeys, OIDC, TOTP and trusted account pages",
|
|
6
6
|
"license": "Apache-2.0",
|