wirejs-resources 0.1.8-alpha → 0.1.9
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/adapters/context.d.ts +1 -1
- package/dist/adapters/context.js +6 -2
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +68 -0
- package/dist/derived-types.d.ts +2 -0
- package/dist/derived-types.js +1 -0
- package/dist/hosting/client.d.ts +1 -0
- package/dist/hosting/client.js +68 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/internal/client.d.ts +1 -0
- package/dist/internal/client.js +68 -0
- package/dist/internal/index.d.ts +1 -0
- package/dist/internal/index.js +27 -0
- package/dist/services/authentication.d.ts +8 -51
- package/dist/services/authentication.js +231 -107
- package/dist/setup/index.d.ts +1 -0
- package/dist/setup/index.js +27 -0
- package/dist/types.d.ts +70 -0
- package/dist/types.js +1 -0
- package/package.json +10 -2
|
@@ -3,7 +3,7 @@ type ApiMethod = (...args: any) => any;
|
|
|
3
3
|
type ApiNamespace = {
|
|
4
4
|
[K in string]: ApiMethod | ApiNamespace;
|
|
5
5
|
};
|
|
6
|
-
type ContextfulApiMethod<T> = T extends ((...args: infer ARGS) => infer RT) ? ((context: Context |
|
|
6
|
+
type ContextfulApiMethod<T> = T extends ((...args: infer ARGS) => infer RT) ? ((context: Context | undefined | null, ...args: ARGS) => RT extends Promise<any> ? RT : Promise<RT>) : never;
|
|
7
7
|
type ContextfulApiNamespace<T> = {
|
|
8
8
|
[K in keyof T]: T[K] extends ApiMethod ? ContextfulApiMethod<T[K]> : ContextfulApiNamespace<T[K]>;
|
|
9
9
|
};
|
package/dist/adapters/context.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const __requiresContext =
|
|
1
|
+
const __requiresContext = '__requiresContext';
|
|
2
2
|
export function withContext(contextWrapper, path = []) {
|
|
3
3
|
// first param needs to be a function, which enables `Proxy` to implement `apply()`.
|
|
4
4
|
const fnOrNs = new Proxy(function () { }, {
|
|
@@ -20,7 +20,11 @@ export function withContext(contextWrapper, path = []) {
|
|
|
20
20
|
return fnOrNs;
|
|
21
21
|
}
|
|
22
22
|
export function requiresContext(fnOrNS) {
|
|
23
|
-
|
|
23
|
+
Object.defineProperty(fnOrNS, __requiresContext, {
|
|
24
|
+
enumerable: false,
|
|
25
|
+
value: true
|
|
26
|
+
});
|
|
27
|
+
return true;
|
|
24
28
|
}
|
|
25
29
|
export class Context {
|
|
26
30
|
cookies;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function apiTree(INTERNAL_API_URL: string, path?: string[]): () => void;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
async function callApi(INTERNAL_API_URL, method, ...args) {
|
|
2
|
+
function isNode() {
|
|
3
|
+
return typeof args[0]?.cookies?.getAll === 'function';
|
|
4
|
+
}
|
|
5
|
+
function apiUrl() {
|
|
6
|
+
if (isNode()) {
|
|
7
|
+
return INTERNAL_API_URL;
|
|
8
|
+
}
|
|
9
|
+
else {
|
|
10
|
+
return "/api";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
let cookieHeader = {};
|
|
14
|
+
if (isNode()) {
|
|
15
|
+
const context = args[0];
|
|
16
|
+
const cookies = context.cookies.getAll();
|
|
17
|
+
cookieHeader = typeof cookies === 'object'
|
|
18
|
+
? {
|
|
19
|
+
Cookie: Object.entries(cookies).map(kv => kv.join('=')).join('; ')
|
|
20
|
+
}
|
|
21
|
+
: {};
|
|
22
|
+
}
|
|
23
|
+
const response = await fetch(apiUrl(), {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: {
|
|
26
|
+
'Content-Type': 'application/json',
|
|
27
|
+
...cookieHeader
|
|
28
|
+
},
|
|
29
|
+
body: JSON.stringify([{ method, args: [...args] }]),
|
|
30
|
+
});
|
|
31
|
+
const body = await response.json();
|
|
32
|
+
if (isNode()) {
|
|
33
|
+
const context = args[0];
|
|
34
|
+
for (const c of response.headers.getSetCookie()) {
|
|
35
|
+
const parts = c.split(';').map(p => p.trim());
|
|
36
|
+
const flags = parts.slice(1);
|
|
37
|
+
const [name, value] = parts[0].split('=').map(decodeURIComponent);
|
|
38
|
+
const httpOnly = flags.includes('HttpOnly');
|
|
39
|
+
const secure = flags.includes('Secure');
|
|
40
|
+
const maxAgePart = flags.find(f => f.startsWith('Max-Age='))?.split('=')[1];
|
|
41
|
+
context.cookies.set({
|
|
42
|
+
name,
|
|
43
|
+
value,
|
|
44
|
+
httpOnly,
|
|
45
|
+
secure,
|
|
46
|
+
maxAge: maxAgePart ? parseInt(maxAgePart) : undefined
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const error = body[0].error;
|
|
51
|
+
if (error) {
|
|
52
|
+
throw new Error(error);
|
|
53
|
+
}
|
|
54
|
+
const value = body[0].data;
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
;
|
|
58
|
+
export function apiTree(INTERNAL_API_URL, path = []) {
|
|
59
|
+
return new Proxy(function () { }, {
|
|
60
|
+
apply(_target, _thisArg, args) {
|
|
61
|
+
return callApi(INTERNAL_API_URL, path, ...args);
|
|
62
|
+
},
|
|
63
|
+
get(_target, prop) {
|
|
64
|
+
return apiTree(INTERNAL_API_URL, [...path, prop]);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function apiTree(INTERNAL_API_URL: string, path?: string[]): () => void;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
async function callApi(INTERNAL_API_URL, method, ...args) {
|
|
2
|
+
function isNode() {
|
|
3
|
+
return typeof args[0]?.cookies?.getAll === 'function';
|
|
4
|
+
}
|
|
5
|
+
function apiUrl() {
|
|
6
|
+
if (isNode()) {
|
|
7
|
+
return INTERNAL_API_URL;
|
|
8
|
+
}
|
|
9
|
+
else {
|
|
10
|
+
return "/api";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
let cookieHeader = {};
|
|
14
|
+
if (isNode()) {
|
|
15
|
+
const context = args[0];
|
|
16
|
+
const cookies = context.cookies.getAll();
|
|
17
|
+
cookieHeader = typeof cookies === 'object'
|
|
18
|
+
? {
|
|
19
|
+
Cookie: Object.entries(cookies).map(kv => kv.join('=')).join('; ')
|
|
20
|
+
}
|
|
21
|
+
: {};
|
|
22
|
+
}
|
|
23
|
+
const response = await fetch(apiUrl(), {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: {
|
|
26
|
+
'Content-Type': 'application/json',
|
|
27
|
+
...cookieHeader
|
|
28
|
+
},
|
|
29
|
+
body: JSON.stringify([{ method, args: [...args] }]),
|
|
30
|
+
});
|
|
31
|
+
const body = await response.json();
|
|
32
|
+
if (isNode()) {
|
|
33
|
+
const context = args[0];
|
|
34
|
+
for (const c of response.headers.getSetCookie()) {
|
|
35
|
+
const parts = c.split(';').map(p => p.trim());
|
|
36
|
+
const flags = parts.slice(1);
|
|
37
|
+
const [name, value] = parts[0].split('=').map(decodeURIComponent);
|
|
38
|
+
const httpOnly = flags.includes('HttpOnly');
|
|
39
|
+
const secure = flags.includes('Secure');
|
|
40
|
+
const maxAgePart = flags.find(f => f.startsWith('Max-Age='))?.split('=')[1];
|
|
41
|
+
context.cookies.set({
|
|
42
|
+
name,
|
|
43
|
+
value,
|
|
44
|
+
httpOnly,
|
|
45
|
+
secure,
|
|
46
|
+
maxAge: maxAgePart ? parseInt(maxAgePart) : undefined
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const error = body[0].error;
|
|
51
|
+
if (error) {
|
|
52
|
+
throw new Error(error);
|
|
53
|
+
}
|
|
54
|
+
const value = body[0].data;
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
;
|
|
58
|
+
export function apiTree(INTERNAL_API_URL, path = []) {
|
|
59
|
+
return new Proxy(function () { }, {
|
|
60
|
+
apply(_target, _thisArg, args) {
|
|
61
|
+
return callApi(INTERNAL_API_URL, path, ...args);
|
|
62
|
+
},
|
|
63
|
+
get(_target, prop) {
|
|
64
|
+
return apiTree(INTERNAL_API_URL, [...path, prop]);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
;
|
package/dist/index.d.ts
CHANGED
|
@@ -4,3 +4,5 @@ export { CookieJar } from './adapters/cookie-jar.js';
|
|
|
4
4
|
export { withContext, requiresContext, Context } from './adapters/context.js';
|
|
5
5
|
export { Resource } from './resource.js';
|
|
6
6
|
export { overrides } from './overrides.js';
|
|
7
|
+
export * from './types.js';
|
|
8
|
+
export * from './derived-types.js';
|
package/dist/index.js
CHANGED
|
@@ -4,3 +4,5 @@ export { CookieJar } from './adapters/cookie-jar.js';
|
|
|
4
4
|
export { withContext, requiresContext, Context } from './adapters/context.js';
|
|
5
5
|
export { Resource } from './resource.js';
|
|
6
6
|
export { overrides } from './overrides.js';
|
|
7
|
+
export * from './types.js';
|
|
8
|
+
export * from './derived-types.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function apiTree(INTERNAL_API_URL: string, path?: string[]): () => void;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
async function callApi(INTERNAL_API_URL, method, ...args) {
|
|
2
|
+
function isNode() {
|
|
3
|
+
return typeof args[0]?.cookies?.getAll === 'function';
|
|
4
|
+
}
|
|
5
|
+
function apiUrl() {
|
|
6
|
+
if (isNode()) {
|
|
7
|
+
return INTERNAL_API_URL;
|
|
8
|
+
}
|
|
9
|
+
else {
|
|
10
|
+
return "/api";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
let cookieHeader = {};
|
|
14
|
+
if (isNode()) {
|
|
15
|
+
const context = args[0];
|
|
16
|
+
const cookies = context.cookies.getAll();
|
|
17
|
+
cookieHeader = typeof cookies === 'object'
|
|
18
|
+
? {
|
|
19
|
+
Cookie: Object.entries(cookies).map(kv => kv.join('=')).join('; ')
|
|
20
|
+
}
|
|
21
|
+
: {};
|
|
22
|
+
}
|
|
23
|
+
const response = await fetch(apiUrl(), {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: {
|
|
26
|
+
'Content-Type': 'application/json',
|
|
27
|
+
...cookieHeader
|
|
28
|
+
},
|
|
29
|
+
body: JSON.stringify([{ method, args: [...args] }]),
|
|
30
|
+
});
|
|
31
|
+
const body = await response.json();
|
|
32
|
+
if (isNode()) {
|
|
33
|
+
const context = args[0];
|
|
34
|
+
for (const c of response.headers.getSetCookie()) {
|
|
35
|
+
const parts = c.split(';').map(p => p.trim());
|
|
36
|
+
const flags = parts.slice(1);
|
|
37
|
+
const [name, value] = parts[0].split('=').map(decodeURIComponent);
|
|
38
|
+
const httpOnly = flags.includes('HttpOnly');
|
|
39
|
+
const secure = flags.includes('Secure');
|
|
40
|
+
const maxAgePart = flags.find(f => f.startsWith('Max-Age='))?.split('=')[1];
|
|
41
|
+
context.cookies.set({
|
|
42
|
+
name,
|
|
43
|
+
value,
|
|
44
|
+
httpOnly,
|
|
45
|
+
secure,
|
|
46
|
+
maxAge: maxAgePart ? parseInt(maxAgePart) : undefined
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const error = body[0].error;
|
|
51
|
+
if (error) {
|
|
52
|
+
throw new Error(error);
|
|
53
|
+
}
|
|
54
|
+
const value = body[0].data;
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
;
|
|
58
|
+
export function apiTree(INTERNAL_API_URL, path = []) {
|
|
59
|
+
return new Proxy(function () { }, {
|
|
60
|
+
apply(_target, _thisArg, args) {
|
|
61
|
+
return callApi(INTERNAL_API_URL, path, ...args);
|
|
62
|
+
},
|
|
63
|
+
get(_target, prop) {
|
|
64
|
+
return apiTree(INTERNAL_API_URL, [...path, prop]);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function prebuildApi(): Promise<void>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import process from 'process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
export async function prebuildApi() {
|
|
5
|
+
const CWD = process.cwd();
|
|
6
|
+
let API_URL = '/api';
|
|
7
|
+
const indexModule = await import(path.join(CWD, 'index.js'));
|
|
8
|
+
try {
|
|
9
|
+
const backendConfigModule = await import(path.join(CWD, 'config.js'));
|
|
10
|
+
const backendConfig = backendConfigModule.default;
|
|
11
|
+
console.log("backend config found", backendConfig);
|
|
12
|
+
if (backendConfig.apiUrl) {
|
|
13
|
+
API_URL = backendConfig.apiUrl;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
console.log("No backend API config found.");
|
|
18
|
+
}
|
|
19
|
+
const apiCode = Object.keys(indexModule)
|
|
20
|
+
.map(k => `export const ${k} = apiTree(INTERNAL_API_URL, ${JSON.stringify([k])});`)
|
|
21
|
+
.join('\n');
|
|
22
|
+
const baseClient = [
|
|
23
|
+
`import { apiTree } from "wirejs-resources/client";`,
|
|
24
|
+
`const INTERNAL_API_URL = ${JSON.stringify(API_URL)};`,
|
|
25
|
+
].join('\n');
|
|
26
|
+
await fs.promises.writeFile(path.join(CWD, 'index.client.js'), [baseClient, apiCode].join('\n\n'));
|
|
27
|
+
}
|
|
@@ -1,67 +1,24 @@
|
|
|
1
1
|
import { Resource } from '../resource.js';
|
|
2
2
|
import type { CookieJar } from '../adapters/cookie-jar.js';
|
|
3
|
-
type
|
|
4
|
-
label: string;
|
|
5
|
-
type: 'text' | 'password';
|
|
6
|
-
isRequired?: boolean;
|
|
7
|
-
};
|
|
8
|
-
type Action = {
|
|
9
|
-
name: string;
|
|
10
|
-
title?: string;
|
|
11
|
-
description?: string;
|
|
12
|
-
message?: string;
|
|
13
|
-
inputs?: Record<string, AuthenticationInput>;
|
|
14
|
-
buttons?: string[];
|
|
15
|
-
};
|
|
16
|
-
type AuthenticationBaseState = {
|
|
17
|
-
state: 'authenticated' | 'unauthenticated';
|
|
18
|
-
user: string | undefined;
|
|
19
|
-
};
|
|
20
|
-
type AuthenticationState = {
|
|
21
|
-
state: AuthenticationBaseState;
|
|
22
|
-
message?: string;
|
|
23
|
-
actions: Record<string, Action>;
|
|
24
|
-
};
|
|
25
|
-
type PerformActionParameter = {
|
|
26
|
-
key: string;
|
|
27
|
-
inputs: Record<string, string | number | boolean>;
|
|
28
|
-
verb: string;
|
|
29
|
-
};
|
|
30
|
-
type AuthenticationError = {
|
|
31
|
-
message: string;
|
|
32
|
-
field?: string;
|
|
33
|
-
};
|
|
34
|
-
type AuthenticationServiceOptions = {
|
|
35
|
-
/**
|
|
36
|
-
* The number of seconds the authentication session stays alive.
|
|
37
|
-
*/
|
|
38
|
-
duration?: number;
|
|
39
|
-
/**
|
|
40
|
-
* Whether to automatically extend (keep alive) an authentication session when used.
|
|
41
|
-
*/
|
|
42
|
-
keepalive?: boolean;
|
|
43
|
-
/**
|
|
44
|
-
* The name of the cookie to use to store the authentication state JWT.
|
|
45
|
-
*/
|
|
46
|
-
cookie?: string;
|
|
47
|
-
};
|
|
3
|
+
import type { User, AuthenticationError, AuthenticationMachineInput, AuthenticationMachineState, AuthenticationServiceOptions, AuthenticationState } from '../types.js';
|
|
48
4
|
export declare class AuthenticationService extends Resource {
|
|
49
5
|
#private;
|
|
50
6
|
constructor(scope: Resource | string, id: string, { duration, keepalive, cookie }?: AuthenticationServiceOptions);
|
|
51
7
|
getSigningSecret(): Promise<Uint8Array<ArrayBufferLike>>;
|
|
52
8
|
get signingSecret(): Promise<Uint8Array<ArrayBufferLike>>;
|
|
53
|
-
getBaseState(cookies: CookieJar): Promise<AuthenticationBaseState>;
|
|
54
9
|
getState(cookies: CookieJar): Promise<AuthenticationState>;
|
|
55
|
-
|
|
10
|
+
getMachineState(cookies: CookieJar): Promise<AuthenticationMachineState>;
|
|
11
|
+
setState(cookies: CookieJar, user?: User): Promise<void>;
|
|
56
12
|
missingFieldErrors(input: Record<string, string | number | boolean>, fields: string[]): AuthenticationError[] | undefined;
|
|
57
|
-
|
|
13
|
+
setMachineState(cookies: CookieJar, form: AuthenticationMachineInput): Promise<AuthenticationMachineState | {
|
|
58
14
|
errors: AuthenticationError[];
|
|
59
15
|
}>;
|
|
60
16
|
buildApi(this: AuthenticationService): {
|
|
61
|
-
getState: (context: import("../adapters/context.js").Context |
|
|
62
|
-
setState: (context: import("../adapters/context.js").Context |
|
|
17
|
+
getState: (context: import("../adapters/context.js").Context | undefined | null) => Promise<AuthenticationMachineState>;
|
|
18
|
+
setState: (context: import("../adapters/context.js").Context | undefined | null, options: AuthenticationMachineInput) => Promise<AuthenticationMachineState | {
|
|
63
19
|
errors: AuthenticationError[];
|
|
64
20
|
}>;
|
|
21
|
+
getCurrentUser: (context: import("../adapters/context.js").Context | undefined | null) => Promise<User | null>;
|
|
22
|
+
requireCurrentUser: (context: import("../adapters/context.js").Context | undefined | null) => Promise<User>;
|
|
65
23
|
};
|
|
66
24
|
}
|
|
67
|
-
export {};
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { scrypt, randomBytes } from 'crypto';
|
|
1
|
+
import { scrypt, randomBytes, randomUUID } from 'crypto';
|
|
2
2
|
import * as jose from 'jose';
|
|
3
3
|
import { Resource } from '../resource.js';
|
|
4
4
|
import { FileService } from './file.js';
|
|
5
5
|
import { Secret } from '../resources/secret.js';
|
|
6
6
|
import { withContext } from '../adapters/context.js';
|
|
7
7
|
import { overrides } from '../overrides.js';
|
|
8
|
+
function newId() {
|
|
9
|
+
return randomUUID();
|
|
10
|
+
}
|
|
8
11
|
function hash(password, salt) {
|
|
9
12
|
return new Promise((resolve, reject) => {
|
|
10
13
|
const finalSalt = salt || randomBytes(16).toString('hex');
|
|
@@ -23,8 +26,160 @@ async function verifyHash(password, passwordHash) {
|
|
|
23
26
|
const rehashed = await hash(password, saltPart);
|
|
24
27
|
return rehashed === passwordHash;
|
|
25
28
|
}
|
|
26
|
-
|
|
29
|
+
const actions = {
|
|
30
|
+
changepassword: {
|
|
31
|
+
name: "Change Password",
|
|
32
|
+
fields: {
|
|
33
|
+
existingPassword: {
|
|
34
|
+
label: 'Old Password',
|
|
35
|
+
type: 'password',
|
|
36
|
+
},
|
|
37
|
+
newPassword: {
|
|
38
|
+
label: 'New Password',
|
|
39
|
+
type: 'password',
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
buttons: ['Change Password']
|
|
43
|
+
},
|
|
44
|
+
signin: {
|
|
45
|
+
name: "Sign In",
|
|
46
|
+
fields: {
|
|
47
|
+
username: {
|
|
48
|
+
label: 'Username',
|
|
49
|
+
type: 'text',
|
|
50
|
+
},
|
|
51
|
+
password: {
|
|
52
|
+
label: 'Password',
|
|
53
|
+
type: 'password',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
buttons: ['Sign In']
|
|
57
|
+
},
|
|
58
|
+
signup: {
|
|
59
|
+
name: "Sign Up",
|
|
60
|
+
fields: {
|
|
61
|
+
username: {
|
|
62
|
+
label: 'Username',
|
|
63
|
+
type: 'text',
|
|
64
|
+
},
|
|
65
|
+
password: {
|
|
66
|
+
label: 'Password',
|
|
67
|
+
type: 'password',
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
buttons: ['Sign Up']
|
|
71
|
+
},
|
|
72
|
+
signout: {
|
|
73
|
+
name: "Sign out"
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
function machineAction(key) {
|
|
77
|
+
return {
|
|
78
|
+
key,
|
|
79
|
+
...actions[key]
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function isAction(input, action) {
|
|
83
|
+
return input.key === action;
|
|
84
|
+
}
|
|
85
|
+
function hasNonEmptyString(o, k) {
|
|
86
|
+
return (typeof o === 'object' && k in o && typeof o[k] === 'string' && o[k].length > 0);
|
|
87
|
+
}
|
|
88
|
+
function isInternalUser(candidate) {
|
|
89
|
+
return (hasNonEmptyString(candidate, 'id')
|
|
90
|
+
&& hasNonEmptyString(candidate, 'password'));
|
|
91
|
+
}
|
|
27
92
|
const ONE_WEEK = 7 * 24 * 60 * 60; // days * hours/day * minutes/hour * seconds/minute
|
|
93
|
+
class UserStore {
|
|
94
|
+
files;
|
|
95
|
+
constructor(files) {
|
|
96
|
+
this.files = files;
|
|
97
|
+
}
|
|
98
|
+
async get(username) {
|
|
99
|
+
try {
|
|
100
|
+
const data = await this.files.read(`byUsername/${username}.json`);
|
|
101
|
+
const parsed = JSON.parse(data);
|
|
102
|
+
return isInternalUser(parsed) ? parsed : null;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async reserveId(username) {
|
|
109
|
+
const candidateId = newId();
|
|
110
|
+
try {
|
|
111
|
+
await this.files.write(`byId/${candidateId}.json`, JSON.stringify({ username }), { onlyIfNotExists: true });
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
if (this.files.isAlreadyExistsError(err)) {
|
|
115
|
+
return this.reserveId(username);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return candidateId;
|
|
122
|
+
}
|
|
123
|
+
async releaseId(id) {
|
|
124
|
+
return this.files.delete(`byId/${id}.json`);
|
|
125
|
+
}
|
|
126
|
+
async createUserEntry(user, retries = 3) {
|
|
127
|
+
try {
|
|
128
|
+
await this.files.write(`byUsername/${user.username}.json`, JSON.stringify(user), { onlyIfNotExists: true });
|
|
129
|
+
return 'ok';
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
if (this.files.isAlreadyExistsError(err)) {
|
|
133
|
+
return 'already-exists';
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
if (retries > 0) {
|
|
137
|
+
await new Promise(unsleep => setTimeout(unsleep, 500));
|
|
138
|
+
return this.createUserEntry(user, retries - 1);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
return 'fail';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async create(username, password) {
|
|
147
|
+
const id = await this.reserveId(username);
|
|
148
|
+
const user = { id, username, password };
|
|
149
|
+
const result = await this.createUserEntry(user);
|
|
150
|
+
switch (result) {
|
|
151
|
+
case 'already-exists':
|
|
152
|
+
case 'fail':
|
|
153
|
+
try {
|
|
154
|
+
await this.releaseId(id);
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
case 'ok':
|
|
160
|
+
return user;
|
|
161
|
+
default:
|
|
162
|
+
throw new Error("Unrecognized result from filesystem: " + result);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async update(username, details) {
|
|
166
|
+
await this.files.write(`byUsername/${username}`, JSON.stringify(details));
|
|
167
|
+
}
|
|
168
|
+
async has(username) {
|
|
169
|
+
const user = await this.get(username);
|
|
170
|
+
return !!user;
|
|
171
|
+
}
|
|
172
|
+
async getById(id) {
|
|
173
|
+
try {
|
|
174
|
+
const idJSON = await this.files.read(`byId/${id}.json`);
|
|
175
|
+
const { username } = JSON.parse(idJSON);
|
|
176
|
+
return this.get(username);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
28
183
|
export class AuthenticationService extends Resource {
|
|
29
184
|
#duration;
|
|
30
185
|
;
|
|
@@ -40,28 +195,7 @@ export class AuthenticationService extends Resource {
|
|
|
40
195
|
this.#cookieName = cookie ?? 'identity';
|
|
41
196
|
this.#rawSigningSecret = new (overrides.Secret || Secret)(this, 'jwt-signing-secret');
|
|
42
197
|
const fileService = new (overrides.FileService || FileService)(this, 'files');
|
|
43
|
-
this.#users =
|
|
44
|
-
id,
|
|
45
|
-
async get(username) {
|
|
46
|
-
try {
|
|
47
|
-
const data = await fileService.read(this.filenameFor(username));
|
|
48
|
-
return JSON.parse(data);
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
return undefined;
|
|
52
|
-
}
|
|
53
|
-
},
|
|
54
|
-
async set(username, details) {
|
|
55
|
-
await fileService.write(this.filenameFor(username), JSON.stringify(details));
|
|
56
|
-
},
|
|
57
|
-
async has(username) {
|
|
58
|
-
const user = await this.get(username);
|
|
59
|
-
return !!user;
|
|
60
|
-
},
|
|
61
|
-
filenameFor(username) {
|
|
62
|
-
return `${username}.json`;
|
|
63
|
-
}
|
|
64
|
-
};
|
|
198
|
+
this.#users = new UserStore(fileService);
|
|
65
199
|
}
|
|
66
200
|
async getSigningSecret() {
|
|
67
201
|
const secretAsString = await this.#rawSigningSecret.read();
|
|
@@ -73,13 +207,17 @@ export class AuthenticationService extends Resource {
|
|
|
73
207
|
}
|
|
74
208
|
return this.#signingSecret;
|
|
75
209
|
}
|
|
76
|
-
async
|
|
210
|
+
async getState(cookies) {
|
|
77
211
|
let idCookie;
|
|
78
212
|
let user;
|
|
79
213
|
try {
|
|
80
214
|
idCookie = cookies.get(this.#cookieName)?.value;
|
|
81
215
|
const idPayload = idCookie ? (await jose.jwtVerify(idCookie, await this.signingSecret)) : undefined;
|
|
82
|
-
user = idPayload ?
|
|
216
|
+
user = idPayload ? {
|
|
217
|
+
id: idPayload.payload.sub,
|
|
218
|
+
username: idPayload.payload.username,
|
|
219
|
+
displayName: idPayload.payload.username,
|
|
220
|
+
} : undefined;
|
|
83
221
|
}
|
|
84
222
|
catch (err) {
|
|
85
223
|
// jose doesn't like our cookie.
|
|
@@ -98,79 +236,38 @@ export class AuthenticationService extends Resource {
|
|
|
98
236
|
};
|
|
99
237
|
}
|
|
100
238
|
}
|
|
101
|
-
async
|
|
102
|
-
const state = await this.
|
|
239
|
+
async getMachineState(cookies) {
|
|
240
|
+
const state = await this.getState(cookies);
|
|
103
241
|
if (state.state === 'authenticated') {
|
|
104
242
|
if (this.#keepalive)
|
|
105
|
-
this.
|
|
243
|
+
this.setState(cookies, state.user);
|
|
106
244
|
return {
|
|
107
|
-
state,
|
|
245
|
+
...state,
|
|
108
246
|
actions: {
|
|
109
|
-
changepassword:
|
|
110
|
-
|
|
111
|
-
inputs: {
|
|
112
|
-
existingPassword: {
|
|
113
|
-
label: 'Old Password',
|
|
114
|
-
type: 'password',
|
|
115
|
-
},
|
|
116
|
-
newPassword: {
|
|
117
|
-
label: 'New Password',
|
|
118
|
-
type: 'password',
|
|
119
|
-
}
|
|
120
|
-
},
|
|
121
|
-
buttons: ['Change Password']
|
|
122
|
-
},
|
|
123
|
-
signout: {
|
|
124
|
-
name: "Sign out"
|
|
125
|
-
},
|
|
247
|
+
changepassword: machineAction('changepassword'),
|
|
248
|
+
signout: machineAction('signout'),
|
|
126
249
|
}
|
|
127
250
|
};
|
|
128
251
|
}
|
|
129
252
|
else {
|
|
130
253
|
return {
|
|
131
|
-
state,
|
|
254
|
+
...state,
|
|
132
255
|
actions: {
|
|
133
|
-
signin:
|
|
134
|
-
|
|
135
|
-
inputs: {
|
|
136
|
-
username: {
|
|
137
|
-
label: 'Username',
|
|
138
|
-
type: 'text',
|
|
139
|
-
},
|
|
140
|
-
password: {
|
|
141
|
-
label: 'Password',
|
|
142
|
-
type: 'password',
|
|
143
|
-
},
|
|
144
|
-
},
|
|
145
|
-
buttons: ['Sign In']
|
|
146
|
-
},
|
|
147
|
-
signup: {
|
|
148
|
-
name: "Sign Up",
|
|
149
|
-
inputs: {
|
|
150
|
-
username: {
|
|
151
|
-
label: 'Username',
|
|
152
|
-
type: 'text',
|
|
153
|
-
},
|
|
154
|
-
password: {
|
|
155
|
-
label: 'Password',
|
|
156
|
-
type: 'password',
|
|
157
|
-
},
|
|
158
|
-
},
|
|
159
|
-
buttons: ['Sign Up']
|
|
160
|
-
},
|
|
256
|
+
signin: machineAction('signin'),
|
|
257
|
+
signup: machineAction('signup'),
|
|
161
258
|
}
|
|
162
259
|
};
|
|
163
260
|
}
|
|
164
261
|
}
|
|
165
|
-
async
|
|
262
|
+
async setState(cookies, user) {
|
|
166
263
|
if (!user) {
|
|
167
264
|
cookies.delete(this.#cookieName);
|
|
168
265
|
}
|
|
169
266
|
else {
|
|
170
|
-
const jwt = await new jose.SignJWT(
|
|
267
|
+
const jwt = await new jose.SignJWT(user)
|
|
171
268
|
.setProtectedHeader({ alg: 'HS256' })
|
|
172
269
|
.setIssuedAt()
|
|
173
|
-
.setSubject(user)
|
|
270
|
+
.setSubject(user.id)
|
|
174
271
|
.setExpirationTime(`${this.#duration}s`)
|
|
175
272
|
.sign(await this.signingSecret);
|
|
176
273
|
cookies.set({
|
|
@@ -193,34 +290,39 @@ export class AuthenticationService extends Resource {
|
|
|
193
290
|
}
|
|
194
291
|
return errors.length > 0 ? errors : undefined;
|
|
195
292
|
}
|
|
196
|
-
async
|
|
197
|
-
if (
|
|
198
|
-
await this.
|
|
199
|
-
return this.
|
|
293
|
+
async setMachineState(cookies, form) {
|
|
294
|
+
if (isAction(form, 'signout')) {
|
|
295
|
+
await this.setState(cookies, undefined);
|
|
296
|
+
return this.getMachineState(cookies);
|
|
200
297
|
}
|
|
201
|
-
else if (
|
|
202
|
-
const errors = this.missingFieldErrors(inputs, ['username', 'password']);
|
|
298
|
+
else if (isAction(form, 'signup')) {
|
|
299
|
+
const errors = this.missingFieldErrors(form.inputs, ['username', 'password']);
|
|
203
300
|
if (errors) {
|
|
204
301
|
return { errors };
|
|
205
302
|
}
|
|
206
|
-
|
|
303
|
+
const createResult = await this.#users.create(form.inputs.username, await hash(form.inputs.password));
|
|
304
|
+
if (createResult === 'already-exists') {
|
|
207
305
|
return { errors: [{
|
|
208
306
|
field: 'username',
|
|
209
307
|
message: 'User already exists.'
|
|
210
|
-
}]
|
|
211
|
-
|
|
308
|
+
}] };
|
|
309
|
+
}
|
|
310
|
+
else if (createResult === 'fail') {
|
|
311
|
+
return { errors: [{
|
|
312
|
+
message: 'Internal error. Please try again.'
|
|
313
|
+
}] };
|
|
212
314
|
}
|
|
213
315
|
else {
|
|
214
|
-
await this
|
|
215
|
-
id:
|
|
216
|
-
|
|
316
|
+
await this.setState(cookies, {
|
|
317
|
+
id: createResult.id,
|
|
318
|
+
username: createResult.username,
|
|
319
|
+
displayName: createResult.username,
|
|
217
320
|
});
|
|
218
|
-
|
|
219
|
-
return this.getState(cookies);
|
|
321
|
+
return this.getMachineState(cookies);
|
|
220
322
|
}
|
|
221
323
|
}
|
|
222
|
-
else if (
|
|
223
|
-
const user = await this.#users.get(inputs.username);
|
|
324
|
+
else if (isAction(form, 'signin')) {
|
|
325
|
+
const user = await this.#users.get(form.inputs.username);
|
|
224
326
|
if (!user) {
|
|
225
327
|
return { errors: [{
|
|
226
328
|
field: 'username',
|
|
@@ -228,11 +330,15 @@ export class AuthenticationService extends Resource {
|
|
|
228
330
|
}]
|
|
229
331
|
};
|
|
230
332
|
}
|
|
231
|
-
else if (await verifyHash(inputs.password, user.password)) {
|
|
333
|
+
else if (await verifyHash(form.inputs.password, user.password)) {
|
|
232
334
|
// a real authentication service will use password hashing.
|
|
233
335
|
// this is an in-memory just-for-testing user pool.
|
|
234
|
-
await this.
|
|
235
|
-
|
|
336
|
+
await this.setState(cookies, {
|
|
337
|
+
id: user.id,
|
|
338
|
+
username: user.username,
|
|
339
|
+
displayName: user.username,
|
|
340
|
+
});
|
|
341
|
+
return this.getMachineState(cookies);
|
|
236
342
|
}
|
|
237
343
|
else {
|
|
238
344
|
return { errors: [{
|
|
@@ -242,9 +348,9 @@ export class AuthenticationService extends Resource {
|
|
|
242
348
|
};
|
|
243
349
|
}
|
|
244
350
|
}
|
|
245
|
-
else if (
|
|
246
|
-
const state = await this.
|
|
247
|
-
const user = await this.#users.get(state.user);
|
|
351
|
+
else if (isAction(form, 'changepassword')) {
|
|
352
|
+
const state = await this.getState(cookies);
|
|
353
|
+
const user = state.user ? await this.#users.get(state.user.username) : null;
|
|
248
354
|
if (!user) {
|
|
249
355
|
return { errors: [{
|
|
250
356
|
field: 'username',
|
|
@@ -252,14 +358,14 @@ export class AuthenticationService extends Resource {
|
|
|
252
358
|
}]
|
|
253
359
|
};
|
|
254
360
|
}
|
|
255
|
-
else if (await verifyHash(inputs.existingPassword, user.password)) {
|
|
256
|
-
await this.#users.
|
|
361
|
+
else if (await verifyHash(form.inputs.existingPassword, user.password)) {
|
|
362
|
+
await this.#users.update(user.username, {
|
|
257
363
|
...user,
|
|
258
|
-
password: await hash(inputs.newPassword)
|
|
364
|
+
password: await hash(form.inputs.newPassword)
|
|
259
365
|
});
|
|
260
366
|
return {
|
|
367
|
+
...await this.getMachineState(cookies),
|
|
261
368
|
message: "Password updated.",
|
|
262
|
-
...await this.getState(cookies)
|
|
263
369
|
};
|
|
264
370
|
}
|
|
265
371
|
else {
|
|
@@ -279,8 +385,26 @@ export class AuthenticationService extends Resource {
|
|
|
279
385
|
}
|
|
280
386
|
buildApi() {
|
|
281
387
|
return withContext(context => ({
|
|
282
|
-
getState: () => this.
|
|
283
|
-
setState: (options) => this.
|
|
388
|
+
getState: () => this.getMachineState(context.cookies),
|
|
389
|
+
setState: (options) => this.setMachineState(context.cookies, options),
|
|
390
|
+
getCurrentUser: async () => {
|
|
391
|
+
const state = await this.getState(context.cookies);
|
|
392
|
+
if (state.state === 'authenticated' && state.user) {
|
|
393
|
+
return state.user;
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
requireCurrentUser: async () => {
|
|
400
|
+
const state = await this.getState(context.cookies);
|
|
401
|
+
if (state.state === 'authenticated' && state.user) {
|
|
402
|
+
return state.user;
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
throw new Error("Unauthorized.");
|
|
406
|
+
}
|
|
407
|
+
}
|
|
284
408
|
}));
|
|
285
409
|
}
|
|
286
410
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function prebuildApi(): Promise<void>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import process from 'process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
export async function prebuildApi() {
|
|
5
|
+
const CWD = process.cwd();
|
|
6
|
+
let API_URL = '/api';
|
|
7
|
+
const indexModule = await import(path.join(CWD, 'index.js'));
|
|
8
|
+
try {
|
|
9
|
+
const backendConfigModule = await import(path.join(CWD, 'config.js'));
|
|
10
|
+
const backendConfig = backendConfigModule.default;
|
|
11
|
+
console.log("backend config found", backendConfig);
|
|
12
|
+
if (backendConfig.apiUrl) {
|
|
13
|
+
API_URL = backendConfig.apiUrl;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
console.log("No backend API config found.");
|
|
18
|
+
}
|
|
19
|
+
const apiCode = Object.keys(indexModule)
|
|
20
|
+
.map(k => `export const ${k} = apiTree(INTERNAL_API_URL, ${JSON.stringify([k])});`)
|
|
21
|
+
.join('\n');
|
|
22
|
+
const baseClient = [
|
|
23
|
+
`import { apiTree } from "wirejs-resources/hosting/client.js";`,
|
|
24
|
+
`const INTERNAL_API_URL = ${JSON.stringify(API_URL)};`,
|
|
25
|
+
].join('\n');
|
|
26
|
+
await fs.promises.writeFile(path.join(CWD, 'index.client.js'), [baseClient, apiCode].join('\n\n'));
|
|
27
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export type User = {
|
|
2
|
+
/**
|
|
3
|
+
* Something that uniquely identifies the user in the authentication system
|
|
4
|
+
* and does not change over time.
|
|
5
|
+
*/
|
|
6
|
+
id: string;
|
|
7
|
+
/**
|
|
8
|
+
* The self-identifier the user has chosen to log in with.
|
|
9
|
+
*/
|
|
10
|
+
username: string;
|
|
11
|
+
/**
|
|
12
|
+
* The name this user will be known by to other users.
|
|
13
|
+
*/
|
|
14
|
+
displayName: string;
|
|
15
|
+
};
|
|
16
|
+
export type AuthenticationField = {
|
|
17
|
+
label: string;
|
|
18
|
+
type: 'text' | 'password';
|
|
19
|
+
isRequired?: boolean;
|
|
20
|
+
};
|
|
21
|
+
export type AuthenticationAction = {
|
|
22
|
+
name: string;
|
|
23
|
+
title?: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
message?: string;
|
|
26
|
+
fields?: Record<string, AuthenticationField>;
|
|
27
|
+
buttons?: readonly string[];
|
|
28
|
+
};
|
|
29
|
+
export type AuthenticationState = {
|
|
30
|
+
state: 'authenticated' | 'unauthenticated';
|
|
31
|
+
user: User | undefined;
|
|
32
|
+
};
|
|
33
|
+
export type AuthenticationMachineAction = Readonly<AuthenticationAction & {
|
|
34
|
+
key: string;
|
|
35
|
+
}>;
|
|
36
|
+
export type AuthenticationMachineState = AuthenticationState & {
|
|
37
|
+
message?: string;
|
|
38
|
+
actions: Record<string, AuthenticationMachineAction>;
|
|
39
|
+
};
|
|
40
|
+
export type AuthenticationMachineInput = {
|
|
41
|
+
key: string;
|
|
42
|
+
inputs?: Record<string, string | number | boolean>;
|
|
43
|
+
verb?: string;
|
|
44
|
+
};
|
|
45
|
+
export type AuthenticationMachineInputFor<T extends AuthenticationMachineAction> = {
|
|
46
|
+
key: T['key'];
|
|
47
|
+
verb: string;
|
|
48
|
+
inputs: T['fields'] extends undefined ? Record<string, never> : {
|
|
49
|
+
[K in keyof T['fields']]: FieldInputFor<T['fields'][K]>;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
export type FieldInputFor<T> = T extends AuthenticationField ? T['type'] extends ('text' | 'password') ? string : never : never;
|
|
53
|
+
export type AuthenticationError = {
|
|
54
|
+
message: string;
|
|
55
|
+
field?: string;
|
|
56
|
+
};
|
|
57
|
+
export type AuthenticationServiceOptions = {
|
|
58
|
+
/**
|
|
59
|
+
* The number of seconds the authentication session stays alive.
|
|
60
|
+
*/
|
|
61
|
+
duration?: number;
|
|
62
|
+
/**
|
|
63
|
+
* Whether to automatically extend (keep alive) an authentication session when used.
|
|
64
|
+
*/
|
|
65
|
+
keepalive?: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* The name of the cookie to use to store the authentication state JWT.
|
|
68
|
+
*/
|
|
69
|
+
cookie?: string;
|
|
70
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wirejs-resources",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Basic services and server-side resources for wirejs apps",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
|
-
"types": "./dist/
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./internal": {
|
|
14
|
+
"types": "./dist/internal/index.d.ts",
|
|
15
|
+
"default": "./dist/internal/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./client": {
|
|
18
|
+
"types": "./dist/client/index.d.ts",
|
|
19
|
+
"default": "./dist/client/index.js"
|
|
12
20
|
}
|
|
13
21
|
},
|
|
14
22
|
"scripts": {
|