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.
@@ -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 | boolean, ...args: ARGS) => RT extends Promise<any> ? RT : Promise<RT>) : never;
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
  };
@@ -1,4 +1,4 @@
1
- const __requiresContext = Symbol('__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
- return fnOrNS[__requiresContext] === true;
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,2 @@
1
+ import type { AuthenticationService } from "./services/authentication";
2
+ export type AuthenticationApi = ReturnType<AuthenticationService['buildApi']>;
@@ -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 AuthenticationInput = {
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
- setBaseState(cookies: CookieJar, user?: string): Promise<void>;
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
- setState(cookies: CookieJar, { key, inputs, verb: _verb }: PerformActionParameter): Promise<AuthenticationState | {
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 | boolean) => Promise<AuthenticationState>;
62
- setState: (context: import("../adapters/context.js").Context | boolean, options: PerformActionParameter) => Promise<AuthenticationState | {
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
- // #endregion
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 getBaseState(cookies) {
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 ? idPayload.payload.sub : undefined;
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 getState(cookies) {
102
- const state = await this.getBaseState(cookies);
239
+ async getMachineState(cookies) {
240
+ const state = await this.getState(cookies);
103
241
  if (state.state === 'authenticated') {
104
242
  if (this.#keepalive)
105
- this.setBaseState(cookies, state.user);
243
+ this.setState(cookies, state.user);
106
244
  return {
107
- state,
245
+ ...state,
108
246
  actions: {
109
- changepassword: {
110
- name: "Change Password",
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
- name: "Sign In",
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 setBaseState(cookies, user) {
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 setState(cookies, { key, inputs, verb: _verb }) {
197
- if (key === 'signout') {
198
- await this.setBaseState(cookies, undefined);
199
- return this.getState(cookies);
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 (key === 'signup') {
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
- else if (await this.#users.has(inputs.username)) {
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.#users.set(inputs.username, {
215
- id: inputs.username,
216
- password: await hash(inputs.password)
316
+ await this.setState(cookies, {
317
+ id: createResult.id,
318
+ username: createResult.username,
319
+ displayName: createResult.username,
217
320
  });
218
- await this.setBaseState(cookies, inputs.username);
219
- return this.getState(cookies);
321
+ return this.getMachineState(cookies);
220
322
  }
221
323
  }
222
- else if (key === 'signin') {
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.setBaseState(cookies, inputs.username);
235
- return this.getState(cookies);
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 (key === 'changepassword') {
246
- const state = await this.getBaseState(cookies);
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.set(user.id, {
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.getState(context.cookies),
283
- setState: (options) => this.setState(context.cookies, options),
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
+ }
@@ -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.8-alpha",
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/types.d.ts",
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": {