@ankhorage/contracts 1.11.0 → 1.13.0

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.
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import {
4
+ AUTH_OAUTH_PROVIDER_IDS,
5
+ type AuthAdapter,
6
+ type AuthOAuthConfig,
7
+ type AuthSpec,
8
+ } from './index';
9
+
10
+ describe('OAuth auth contracts', () => {
11
+ it('accepts provider-neutral OAuth provider config on auth specs', () => {
12
+ const oauth: AuthOAuthConfig = {
13
+ enabled: true,
14
+ callbackRoute: '/auth/callback',
15
+ providers: [
16
+ {
17
+ id: 'google',
18
+ label: 'Google',
19
+ enabled: true,
20
+ scopes: ['openid', 'email', 'profile'],
21
+ icon: {
22
+ provider: 'FontAwesome',
23
+ name: 'google',
24
+ },
25
+ },
26
+ {
27
+ id: 'custom-sso',
28
+ label: 'Custom SSO',
29
+ enabled: false,
30
+ redirectTo: '/auth/custom/callback',
31
+ queryParams: {
32
+ prompt: 'select_account',
33
+ },
34
+ },
35
+ ],
36
+ };
37
+
38
+ const auth: AuthSpec = {
39
+ scope: 'global',
40
+ provider: 'supabase',
41
+ authorization: { kind: 'RBAC', engine: 'native' },
42
+ oauth,
43
+ };
44
+
45
+ expect(AUTH_OAUTH_PROVIDER_IDS.includes('google')).toBe(true);
46
+ expect(auth.oauth?.providers[0]?.icon?.name).toBe('google');
47
+ expect(auth.oauth?.providers[1]?.id).toBe('custom-sso');
48
+ expect(auth.oauth?.providers[1]?.enabled).toBe(false);
49
+ });
50
+
51
+ it('accepts optional OAuth adapter capabilities and redirect flow methods', async () => {
52
+ const adapter: AuthAdapter = {
53
+ capabilities: {
54
+ signInIdentifiers: ['email'],
55
+ supportsSignUp: true,
56
+ supportsPasswordReset: true,
57
+ supportsOtp: true,
58
+ supportsSessionRefresh: true,
59
+ supportsOAuth: true,
60
+ oauthProviders: ['google', 'custom-sso'],
61
+ },
62
+ signIn() {
63
+ return Promise.resolve({
64
+ ok: false,
65
+ error: { code: 'unsupported', message: 'Password sign-in is not implemented.' },
66
+ });
67
+ },
68
+ signUp() {
69
+ return Promise.resolve({
70
+ ok: false,
71
+ error: { code: 'unsupported', message: 'Sign-up is not implemented.' },
72
+ });
73
+ },
74
+ signOut() {
75
+ return Promise.resolve({ ok: true });
76
+ },
77
+ getSession() {
78
+ return Promise.resolve({ ok: true, data: null });
79
+ },
80
+ signInWithOAuth(input) {
81
+ return Promise.resolve({
82
+ ok: true,
83
+ data: {
84
+ provider: input.provider,
85
+ url: `https://auth.example.com/oauth/${input.provider}`,
86
+ },
87
+ });
88
+ },
89
+ };
90
+
91
+ const result = await adapter.signInWithOAuth?.({
92
+ provider: 'google',
93
+ redirectTo: '/auth/callback',
94
+ scopes: ['openid', 'email', 'profile'],
95
+ });
96
+
97
+ expect(adapter.capabilities?.supportsOAuth).toBe(true);
98
+ expect(adapter.capabilities?.oauthProviders).toEqual(['google', 'custom-sso']);
99
+ expect(result?.ok).toBe(true);
100
+ expect(result?.ok === true ? result.data?.provider : undefined).toBe('google');
101
+ expect(result?.ok === true ? result.data?.url : undefined).toContain('/oauth/google');
102
+ });
103
+ });
package/src/auth.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { IconSpec } from './types';
2
+
1
3
  export const AUTH_IDENTIFIER_KINDS = ['email', 'phone', 'username'] as const;
2
4
  export type AuthIdentifierKind = (typeof AUTH_IDENTIFIER_KINDS)[number];
3
5
 
@@ -11,6 +13,30 @@ export const AUTH_SIGN_UP_FIELDS = [
11
13
  export type KnownAuthSignUpField = (typeof AUTH_SIGN_UP_FIELDS)[number];
12
14
  export type AuthSignUpField = KnownAuthSignUpField | (string & {});
13
15
 
16
+ export const AUTH_OAUTH_PROVIDER_IDS = [
17
+ 'apple',
18
+ 'azure',
19
+ 'bitbucket',
20
+ 'discord',
21
+ 'facebook',
22
+ 'figma',
23
+ 'github',
24
+ 'gitlab',
25
+ 'google',
26
+ 'kakao',
27
+ 'keycloak',
28
+ 'linkedin',
29
+ 'notion',
30
+ 'slack',
31
+ 'spotify',
32
+ 'twitch',
33
+ 'twitter',
34
+ 'workos',
35
+ 'zoom',
36
+ ] as const;
37
+ export type KnownAuthOAuthProviderId = (typeof AUTH_OAUTH_PROVIDER_IDS)[number];
38
+ export type AuthOAuthProviderId = KnownAuthOAuthProviderId | (string & {});
39
+
14
40
  export interface AuthIdentifier {
15
41
  kind: AuthIdentifierKind;
16
42
  value: string;
@@ -35,11 +61,28 @@ export interface AuthSignUpConfig {
35
61
  optionalFields?: AuthSignUpField[];
36
62
  }
37
63
 
64
+ export interface AuthOAuthProviderConfig {
65
+ id: AuthOAuthProviderId;
66
+ label?: string;
67
+ enabled?: boolean;
68
+ scopes?: string[];
69
+ redirectTo?: string;
70
+ queryParams?: Record<string, string>;
71
+ icon?: IconSpec;
72
+ }
73
+
74
+ export interface AuthOAuthConfig {
75
+ enabled: boolean;
76
+ callbackRoute: string;
77
+ providers: AuthOAuthProviderConfig[];
78
+ }
79
+
38
80
  export interface AuthProviderConfig {
39
81
  provider: string;
40
82
  flow: AuthFlowConfig;
41
83
  signIn: AuthSignInConfig;
42
84
  signUp?: AuthSignUpConfig;
85
+ oauth?: AuthOAuthConfig;
43
86
  passwordReset?: {
44
87
  enabled: boolean;
45
88
  };
@@ -114,12 +157,32 @@ export interface VerifyOtpInput {
114
157
  metadata?: Record<string, unknown>;
115
158
  }
116
159
 
160
+ export interface SignInWithOAuthInput {
161
+ provider: AuthOAuthProviderId;
162
+ redirectTo?: string;
163
+ scopes?: string[];
164
+ queryParams?: Record<string, string>;
165
+ metadata?: Record<string, unknown>;
166
+ }
167
+
168
+ export interface AuthOAuthRedirect {
169
+ provider: AuthOAuthProviderId;
170
+ url: string;
171
+ }
172
+
173
+ export interface CompleteOAuthSignInInput {
174
+ url: string;
175
+ redirectTo?: string;
176
+ }
177
+
117
178
  export interface AuthAdapterCapabilities {
118
179
  signInIdentifiers: AuthIdentifierKind[];
119
180
  supportsSignUp: boolean;
120
181
  supportsPasswordReset: boolean;
121
182
  supportsOtp: boolean;
122
183
  supportsSessionRefresh: boolean;
184
+ supportsOAuth?: boolean;
185
+ oauthProviders?: AuthOAuthProviderId[];
123
186
  }
124
187
 
125
188
  export interface AuthAdapter {
@@ -134,4 +197,7 @@ export interface AuthAdapter {
134
197
 
135
198
  requestPasswordReset?(input: PasswordResetInput): Promise<AuthResult>;
136
199
  verifyOtp?(input: VerifyOtpInput): Promise<AuthResult<AuthSession>>;
200
+
201
+ signInWithOAuth?(input: SignInWithOAuthInput): Promise<AuthResult<AuthOAuthRedirect>>;
202
+ completeOAuthSignIn?(input: CompleteOAuthSignInInput): Promise<AuthResult<AuthSession>>;
137
203
  }
@@ -1,96 +1,316 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
2
 
3
3
  import type {
4
+ AppManifest,
5
+ BindingInputMap,
4
6
  BindingValueSource,
5
- DbCollectionQueryBinding,
6
- NodePropBinding,
7
- UiNode,
7
+ ComponentDataBinding,
8
+ ComponentDataBindingRegistry,
9
+ EventBinding,
10
+ PropBinding,
8
11
  } from './index';
9
12
 
10
- describe('binding contracts', () => {
11
- it('serializes a node prop bound to state', () => {
12
- const binding: NodePropBinding = {
13
- prop: 'value',
14
- valueFrom: {
15
- source: 'state',
16
- path: 'forms.contact.values.firstname',
17
- },
18
- transform: 'trim',
19
- };
20
- const node: UiNode = {
21
- id: 'firstname-input',
22
- type: 'Input',
13
+ function assertSerializable<TValue>(value: TValue): void {
14
+ expect(JSON.parse(JSON.stringify(value))).toEqual(value);
15
+ }
16
+
17
+ describe('component data-binding contracts', () => {
18
+ it('serializes a component prop bound to an operation response path', () => {
19
+ const binding: ComponentDataBinding = {
20
+ componentId: 'hero-title',
21
+ componentType: 'Text',
23
22
  props: {
24
- label: 'Firstname',
23
+ children: {
24
+ source: {
25
+ kind: 'operation',
26
+ operation: {
27
+ dataSourceId: 'cms',
28
+ endpointId: 'pages',
29
+ operationId: 'pages.getHome',
30
+ },
31
+ path: '$.data.title',
32
+ },
33
+ fallback: {
34
+ value: 'Untitled',
35
+ },
36
+ loading: {
37
+ state: 'loading',
38
+ fallback: {
39
+ value: 'Loading…',
40
+ },
41
+ },
42
+ error: {
43
+ state: 'error',
44
+ message: 'Could not load title.',
45
+ fallback: {
46
+ value: 'Untitled',
47
+ },
48
+ },
49
+ transforms: ['trim'],
50
+ },
25
51
  },
26
- bindings: [binding],
27
52
  };
28
53
 
29
- expect(JSON.parse(JSON.stringify(node))).toEqual(node);
30
- expect(node.bindings?.[0]?.valueFrom.source).toBe('state');
54
+ assertSerializable(binding);
55
+ expect(binding.props?.children?.source.kind).toBe('operation');
31
56
  });
32
57
 
33
- it('serializes a node prop bound to a DB collection query', () => {
34
- const query: DbCollectionQueryBinding = {
35
- collection: 'posts',
36
- schema: 'public',
37
- columns: ['id', 'title', 'published'],
38
- filters: [{ field: 'published', operator: 'eq', value: true }],
39
- sort: [{ field: 'created_at', direction: 'desc' }],
40
- page: { limit: 10, offset: 0 },
41
- refresh: 'live',
42
- };
43
- const node: UiNode = {
44
- id: 'posts-list',
45
- type: 'CollectionList',
46
- bindings: [
47
- {
48
- prop: 'items',
49
- valueFrom: {
50
- source: 'db.collection',
51
- query,
58
+ it('serializes literal, context, event, state, and operation value sources', () => {
59
+ const sources: readonly BindingValueSource[] = [
60
+ { kind: 'literal', value: { fallback: 'Untitled' } },
61
+ { kind: 'context', path: 'auth.user.displayName' },
62
+ { kind: 'event', path: 'payload.values.search' },
63
+ { kind: 'state', path: 'forms.search.query' },
64
+ {
65
+ kind: 'operation',
66
+ operation: {
67
+ dataSourceId: 'search-api',
68
+ operationId: 'search.query',
69
+ },
70
+ path: '$.results',
71
+ },
72
+ ];
73
+
74
+ assertSerializable(sources);
75
+ expect(sources.map((source) => source.kind)).toEqual([
76
+ 'literal',
77
+ 'context',
78
+ 'event',
79
+ 'state',
80
+ 'operation',
81
+ ]);
82
+ });
83
+
84
+ it('serializes an event binding that executes a data-source operation', () => {
85
+ const input: BindingInputMap = {
86
+ email: {
87
+ kind: 'source',
88
+ source: {
89
+ kind: 'event',
90
+ path: 'payload.values.email',
91
+ },
92
+ transforms: ['trim', 'lowercase'],
93
+ },
94
+ message: {
95
+ kind: 'source',
96
+ source: {
97
+ kind: 'event',
98
+ path: 'payload.values.message',
99
+ },
100
+ },
101
+ source: {
102
+ kind: 'literal',
103
+ value: 'contact-form',
104
+ },
105
+ metadata: {
106
+ kind: 'object',
107
+ fields: {
108
+ userId: {
109
+ kind: 'source',
110
+ source: {
111
+ kind: 'context',
112
+ path: 'auth.user.id',
113
+ },
52
114
  },
53
115
  },
54
- ],
116
+ },
117
+ };
118
+
119
+ const binding: EventBinding = {
120
+ target: {
121
+ kind: 'operation',
122
+ operation: {
123
+ dataSourceId: 'contact-api',
124
+ endpointId: 'messages',
125
+ operationId: 'messages.create',
126
+ },
127
+ },
128
+ input,
129
+ when: {
130
+ source: {
131
+ kind: 'event',
132
+ path: 'payload.values.email',
133
+ },
134
+ operator: 'exists',
135
+ },
55
136
  };
56
137
 
57
- expect(JSON.parse(JSON.stringify(node))).toEqual(node);
58
- expect(node.bindings?.[0]?.valueFrom.source).toBe('db.collection');
138
+ assertSerializable(binding);
139
+ expect(binding.target.kind).toBe('operation');
140
+ expect(binding.input?.email?.kind).toBe('source');
59
141
  });
60
142
 
61
- it('supports literal, context, and event value sources', () => {
62
- const sources: readonly BindingValueSource[] = [
63
- { source: 'literal', value: { fallback: 'Untitled' } },
64
- { source: 'context', path: 'auth.user.displayName' },
65
- { source: 'event', path: 'payload.values.search' },
66
- ];
143
+ it('serializes an event binding that targets a named action', () => {
144
+ const binding: EventBinding = {
145
+ target: {
146
+ kind: 'action',
147
+ type: 'toast.show',
148
+ },
149
+ input: {
150
+ message: {
151
+ kind: 'literal',
152
+ value: 'Saved successfully.',
153
+ },
154
+ },
155
+ };
67
156
 
68
- expect(JSON.parse(JSON.stringify(sources))).toEqual(sources);
69
- expect(sources.map((source) => source.source)).toEqual(['literal', 'context', 'event']);
157
+ assertSerializable(binding);
158
+ expect(binding.target.kind).toBe('action');
70
159
  });
71
160
 
72
- it('supports static, focus, poll, and live refresh intent', () => {
73
- const refreshModes = ['static', 'focus', 'poll', 'live'] as const;
74
- const bindings = refreshModes.map(
75
- (refresh): NodePropBinding => ({
76
- prop: `items.${refresh}`,
77
- valueFrom: {
78
- source: 'db.collection',
79
- query: {
80
- collection: 'posts',
81
- refresh,
82
- pollIntervalMs: refresh === 'poll' ? 30_000 : undefined,
161
+ it('serializes component data-binding registries', () => {
162
+ const bindings: ComponentDataBindingRegistry = {
163
+ 'submit-button': {
164
+ componentId: 'submit-button',
165
+ componentType: 'Button',
166
+ props: {
167
+ children: {
168
+ source: {
169
+ kind: 'literal',
170
+ value: 'Send',
171
+ },
172
+ },
173
+ disabled: {
174
+ source: {
175
+ kind: 'state',
176
+ path: 'forms.contact.isSubmitting',
177
+ },
83
178
  },
84
179
  },
85
- }),
86
- );
180
+ events: {
181
+ press: [
182
+ {
183
+ target: {
184
+ kind: 'operation',
185
+ operation: {
186
+ dataSourceId: 'contact-api',
187
+ operationId: 'messages.create',
188
+ },
189
+ },
190
+ },
191
+ ],
192
+ },
193
+ },
194
+ };
87
195
 
88
- expect(JSON.parse(JSON.stringify(bindings))).toEqual(bindings);
89
- expect(bindings.map((binding) => binding.valueFrom.source)).toEqual([
90
- 'db.collection',
91
- 'db.collection',
92
- 'db.collection',
93
- 'db.collection',
94
- ]);
196
+ assertSerializable(bindings);
197
+ expect(bindings['submit-button']?.events?.press?.[0]?.target.kind).toBe('operation');
198
+ });
199
+
200
+ it('serializes app-level dataSources and dataBindings on the manifest', () => {
201
+ const propBinding: PropBinding = {
202
+ source: {
203
+ kind: 'operation',
204
+ operation: {
205
+ dataSourceId: 'cms',
206
+ endpointId: 'pages',
207
+ operationId: 'pages.getHome',
208
+ },
209
+ path: '$.data.heroTitle',
210
+ },
211
+ };
212
+
213
+ const manifest: AppManifest = {
214
+ metadata: {
215
+ name: 'Demo',
216
+ slug: 'demo',
217
+ version: '1.0.0',
218
+ themeId: 'default',
219
+ },
220
+ themes: [
221
+ {
222
+ id: 'default',
223
+ name: 'Default',
224
+ light: {
225
+ primaryColor: '#3366ff',
226
+ harmony: 'analogous',
227
+ },
228
+ dark: {
229
+ primaryColor: '#3366ff',
230
+ harmony: 'analogous',
231
+ },
232
+ },
233
+ ],
234
+ activeThemeId: 'default',
235
+ infra: {
236
+ plugins: [],
237
+ },
238
+ navigator: {
239
+ type: 'stack',
240
+ routes: [
241
+ {
242
+ name: 'home',
243
+ screenId: 'home',
244
+ },
245
+ ],
246
+ },
247
+ screens: {
248
+ home: {
249
+ id: 'home',
250
+ name: 'Home',
251
+ root: {
252
+ id: 'screen-root',
253
+ type: 'Screen',
254
+ children: [
255
+ {
256
+ id: 'hero-title',
257
+ type: 'Text',
258
+ },
259
+ ],
260
+ },
261
+ },
262
+ },
263
+ dataSources: {
264
+ cms: {
265
+ id: 'cms',
266
+ kind: 'rest',
267
+ baseUrl: 'https://cms.example.com',
268
+ endpoints: {
269
+ pages: {
270
+ id: 'pages',
271
+ kind: 'http',
272
+ path: '/pages/home',
273
+ operations: {
274
+ 'pages.getHome': {
275
+ id: 'pages.getHome',
276
+ endpointId: 'pages',
277
+ protocol: 'http',
278
+ intent: 'read',
279
+ method: 'GET',
280
+ path: '/pages/home',
281
+ },
282
+ },
283
+ },
284
+ },
285
+ },
286
+ },
287
+ dataBindings: {
288
+ 'hero-title': {
289
+ componentId: 'hero-title',
290
+ componentType: 'Text',
291
+ props: {
292
+ children: propBinding,
293
+ },
294
+ },
295
+ },
296
+ settings: {
297
+ localization: {
298
+ defaultLocale: 'en',
299
+ locales: ['en'],
300
+ },
301
+ authFlow: {
302
+ signInRoute: '/sign-in',
303
+ signUpRoute: '/sign-up',
304
+ signOutRoute: '/sign-out',
305
+ forgotPasswordRoute: '/forgot-password',
306
+ postSignInRoute: '/',
307
+ unauthorizedRoute: '/sign-in',
308
+ },
309
+ },
310
+ };
311
+
312
+ assertSerializable(manifest);
313
+ expect(manifest.dataBindings?.['hero-title']?.props?.children?.source.kind).toBe('operation');
314
+ expect(manifest.dataSources?.cms?.kind).toBe('rest');
95
315
  });
96
316
  });