Skip to content

Commit 0189de6

Browse files
Copilothotlong
andcommitted
refactor(cloud): reuse Identity module for auth/org/API keys (better-auth aligned)
- Remove DeveloperAccountSchema, DeveloperApiKeySchema, ApiKeyScopeSchema, DeveloperAccountStatusSchema from developer-portal.zod.ts - Add ApiKeySchema to Identity module following better-auth's API key plugin - Add PublisherProfileSchema that links Identity.Organization to marketplace - Update developer-portal.zod.ts docs to reference Identity namespace Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent ba516b2 commit 0189de6

File tree

4 files changed

+244
-172
lines changed

4 files changed

+244
-172
lines changed

packages/spec/src/cloud/developer-portal.test.ts

Lines changed: 29 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import { describe, it, expect } from 'vitest';
22
import {
3-
DeveloperAccountStatusSchema,
4-
ApiKeyScopeSchema,
5-
DeveloperApiKeySchema,
6-
DeveloperAccountSchema,
3+
PublisherProfileSchema,
74
ReleaseChannelSchema,
85
VersionReleaseSchema,
96
CreateListingRequestSchema,
@@ -15,110 +12,50 @@ import {
1512
TimeSeriesPointSchema,
1613
} from './developer-portal.zod';
1714

18-
describe('DeveloperAccountStatusSchema', () => {
19-
it('should accept all valid statuses', () => {
20-
const statuses = ['pending', 'active', 'suspended', 'deactivated'];
21-
statuses.forEach(status => {
22-
expect(() => DeveloperAccountStatusSchema.parse(status)).not.toThrow();
23-
});
24-
});
25-
26-
it('should reject invalid status', () => {
27-
expect(() => DeveloperAccountStatusSchema.parse('banned')).toThrow();
28-
});
29-
});
30-
31-
describe('ApiKeyScopeSchema', () => {
32-
it('should accept all valid scopes', () => {
33-
const scopes = ['publish', 'read', 'manage', 'admin'];
34-
scopes.forEach(scope => {
35-
expect(() => ApiKeyScopeSchema.parse(scope)).not.toThrow();
36-
});
37-
});
38-
});
39-
40-
describe('DeveloperApiKeySchema', () => {
41-
it('should accept minimal API key', () => {
42-
const key = {
43-
id: 'key-001',
44-
label: 'CI/CD Pipeline',
45-
scopes: ['publish'],
46-
createdAt: '2025-06-01T00:00:00Z',
47-
};
48-
const parsed = DeveloperApiKeySchema.parse(key);
49-
expect(parsed.active).toBe(true);
50-
});
51-
52-
it('should accept full API key', () => {
53-
const key = {
54-
id: 'key-001',
55-
label: 'CI/CD Pipeline',
56-
scopes: ['publish', 'read'],
57-
prefix: 'os_pk_ab',
58-
expiresAt: '2026-06-01T00:00:00Z',
59-
createdAt: '2025-06-01T00:00:00Z',
60-
lastUsedAt: '2025-09-15T10:30:00Z',
61-
active: true,
62-
};
63-
const parsed = DeveloperApiKeySchema.parse(key);
64-
expect(parsed.scopes).toHaveLength(2);
65-
expect(parsed.prefix).toBe('os_pk_ab');
66-
});
67-
68-
it('should require at least one scope', () => {
69-
const key = {
70-
id: 'key-001',
71-
label: 'Empty',
72-
scopes: [],
73-
createdAt: '2025-06-01T00:00:00Z',
74-
};
75-
expect(() => DeveloperApiKeySchema.parse(key)).toThrow();
76-
});
77-
});
78-
79-
describe('DeveloperAccountSchema', () => {
80-
it('should accept minimal account', () => {
81-
const account = {
82-
id: 'dev-001',
15+
describe('PublisherProfileSchema', () => {
16+
it('should accept minimal publisher profile', () => {
17+
const profile = {
18+
organizationId: 'org-001',
8319
publisherId: 'pub-001',
84-
organizationName: 'Acme Corp',
85-
email: 'dev@acme.com',
8620
registeredAt: '2025-01-15T10:00:00Z',
8721
};
88-
const parsed = DeveloperAccountSchema.parse(account);
89-
expect(parsed.status).toBe('pending');
22+
const parsed = PublisherProfileSchema.parse(profile);
9023
expect(parsed.verification).toBe('unverified');
9124
});
9225

93-
it('should accept full account with team members', () => {
94-
const account = {
95-
id: 'dev-001',
26+
it('should accept full publisher profile', () => {
27+
const profile = {
28+
organizationId: 'org-001',
9629
publisherId: 'pub-001',
97-
status: 'active' as const,
9830
verification: 'verified' as const,
99-
organizationName: 'Acme Corp',
100-
email: 'dev@acme.com',
101-
teamMembers: [
102-
{ userId: 'user-001', role: 'owner' as const, joinedAt: '2025-01-15T10:00:00Z' },
103-
{ userId: 'user-002', role: 'developer' as const },
104-
],
10531
agreementVersion: '2.0',
32+
website: 'https://acme.com',
33+
supportEmail: 'support@acme.com',
34+
registeredAt: '2025-01-15T10:00:00Z',
35+
};
36+
const parsed = PublisherProfileSchema.parse(profile);
37+
expect(parsed.verification).toBe('verified');
38+
expect(parsed.agreementVersion).toBe('2.0');
39+
});
40+
41+
it('should require valid support email', () => {
42+
const profile = {
43+
organizationId: 'org-001',
44+
publisherId: 'pub-001',
45+
supportEmail: 'not-an-email',
10646
registeredAt: '2025-01-15T10:00:00Z',
10747
};
108-
const parsed = DeveloperAccountSchema.parse(account);
109-
expect(parsed.teamMembers).toHaveLength(2);
110-
expect(parsed.status).toBe('active');
48+
expect(() => PublisherProfileSchema.parse(profile)).toThrow();
11149
});
11250

113-
it('should require valid email', () => {
114-
const account = {
115-
id: 'dev-001',
51+
it('should require valid website URL', () => {
52+
const profile = {
53+
organizationId: 'org-001',
11654
publisherId: 'pub-001',
117-
organizationName: 'Acme Corp',
118-
email: 'not-an-email',
55+
website: 'not-a-url',
11956
registeredAt: '2025-01-15T10:00:00Z',
12057
};
121-
expect(() => DeveloperAccountSchema.parse(account)).toThrow();
58+
expect(() => PublisherProfileSchema.parse(profile)).toThrow();
12259
});
12360
});
12461

packages/spec/src/cloud/developer-portal.zod.ts

Lines changed: 32 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -18,101 +18,56 @@ import { PublisherVerificationSchema } from './marketplace.zod';
1818
* - **Shopify Partner Dashboard**: App management, analytics, billing
1919
* - **VS Code Marketplace Management**: Extension publishing, statistics, tokens
2020
*
21+
* ## Identity Integration (better-auth)
22+
* Authentication, organization management, and API keys are handled by the
23+
* Identity module (`@objectstack/spec` Identity namespace), which follows the
24+
* better-auth specification. This module only defines marketplace-specific
25+
* extensions on top of the shared identity layer:
26+
*
27+
* - **User & Session** → `Identity.UserSchema`, `Identity.SessionSchema`
28+
* - **Organization & Members** → `Identity.OrganizationSchema`, `Identity.MemberSchema`
29+
* - **API Keys** → `Identity.ApiKeySchema` (with marketplace scopes)
30+
*
2131
* ## Key Concepts
22-
* - **Developer Account**: Registration and API key management
32+
* - **Publisher Profile**: Links an Identity Organization to a marketplace publisher
2333
* - **App Listing Management**: CRUD for marketplace listings (draft → published)
2434
* - **Version Channels**: alpha / beta / rc / stable release channels
2535
* - **Publishing Analytics**: Install trends, revenue, ratings over time
2636
*/
2737

2838
// ==========================================
29-
// Developer Account & API Keys
39+
// Publisher Profile (extends Identity.Organization)
3040
// ==========================================
3141

3242
/**
33-
* Developer Account Status
34-
*/
35-
export const DeveloperAccountStatusSchema = z.enum([
36-
'pending', // Registration submitted, awaiting approval
37-
'active', // Account active and can publish
38-
'suspended', // Temporarily suspended (policy violation)
39-
'deactivated', // Deactivated by developer
40-
]);
41-
42-
/**
43-
* API Key Scope — controls what the key can do
44-
*/
45-
export const ApiKeyScopeSchema = z.enum([
46-
'publish', // Publish packages to registry
47-
'read', // Read listing/analytics data
48-
'manage', // Manage listings (update, deprecate)
49-
'admin', // Full access (manage team, keys)
50-
]);
51-
52-
/**
53-
* Developer API Key
54-
*/
55-
export const DeveloperApiKeySchema = z.object({
56-
/** Key identifier (not the secret) */
57-
id: z.string().describe('API key identifier'),
58-
59-
/** Human-readable label */
60-
label: z.string().describe('Key label (e.g., "CI/CD Pipeline")'),
61-
62-
/** Scopes granted to this key */
63-
scopes: z.array(ApiKeyScopeSchema).min(1).describe('Permissions granted'),
64-
65-
/** Key prefix (first 8 chars) for identification */
66-
prefix: z.string().max(8).optional().describe('Key prefix for display'),
67-
68-
/** Expiration date (optional) */
69-
expiresAt: z.string().datetime().optional(),
70-
71-
/** Creation timestamp */
72-
createdAt: z.string().datetime(),
73-
74-
/** Last used timestamp */
75-
lastUsedAt: z.string().datetime().optional(),
76-
77-
/** Whether this key is currently active */
78-
active: z.boolean().default(true),
79-
});
80-
81-
/**
82-
* Developer Account Schema
43+
* Publisher Profile Schema
8344
*
84-
* Represents a registered developer or organization in the portal.
45+
* Links an Identity Organization to a marketplace publisher identity.
46+
* The organization itself (name, slug, logo, members) is managed via
47+
* Identity.OrganizationSchema and Identity.MemberSchema (better-auth aligned).
48+
*
49+
* This schema only holds marketplace-specific publisher metadata.
8550
*/
86-
export const DeveloperAccountSchema = z.object({
87-
/** Account unique identifier */
88-
id: z.string().describe('Developer account ID'),
89-
90-
/** Publisher ID (links to PublisherSchema in marketplace) */
91-
publisherId: z.string().describe('Associated publisher ID'),
51+
export const PublisherProfileSchema = z.object({
52+
/** Organization ID (references Identity.Organization.id) */
53+
organizationId: z.string().describe('Identity Organization ID'),
9254

93-
/** Account status */
94-
status: DeveloperAccountStatusSchema.default('pending'),
55+
/** Publisher ID (marketplace-assigned identifier) */
56+
publisherId: z.string().describe('Marketplace publisher ID'),
9557

96-
/** Verification level (from marketplace publisher) */
58+
/** Verification level (marketplace trust tier) */
9759
verification: PublisherVerificationSchema.default('unverified'),
9860

99-
/** Organization name */
100-
organizationName: z.string().describe('Organization or developer name'),
101-
102-
/** Primary contact email */
103-
email: z.string().email().describe('Primary contact email'),
61+
/** Accepted developer program agreement version */
62+
agreementVersion: z.string().optional().describe('Accepted developer agreement version'),
10463

105-
/** Team members (user IDs with roles) */
106-
teamMembers: z.array(z.object({
107-
userId: z.string(),
108-
role: z.enum(['owner', 'admin', 'developer', 'viewer']),
109-
joinedAt: z.string().datetime().optional(),
110-
})).optional().describe('Team member list'),
64+
/** Publisher-specific website (may differ from org) */
65+
website: z.string().url().optional().describe('Publisher website'),
11166

112-
/** Accepted developer agreement version */
113-
agreementVersion: z.string().optional().describe('Accepted ToS version'),
67+
/** Publisher-specific support email */
68+
supportEmail: z.string().email().optional().describe('Publisher support email'),
11469

115-
/** Registration timestamp */
70+
/** Registration timestamp (when org became a publisher) */
11671
registeredAt: z.string().datetime(),
11772
});
11873

@@ -354,10 +309,7 @@ export const PublishingAnalyticsResponseSchema = z.object({
354309
// Export Types
355310
// ==========================================
356311

357-
export type DeveloperAccountStatus = z.infer<typeof DeveloperAccountStatusSchema>;
358-
export type ApiKeyScope = z.infer<typeof ApiKeyScopeSchema>;
359-
export type DeveloperApiKey = z.infer<typeof DeveloperApiKeySchema>;
360-
export type DeveloperAccount = z.infer<typeof DeveloperAccountSchema>;
312+
export type PublisherProfile = z.infer<typeof PublisherProfileSchema>;
361313
export type ReleaseChannel = z.infer<typeof ReleaseChannelSchema>;
362314
export type VersionRelease = z.infer<typeof VersionReleaseSchema>;
363315
export type CreateListingRequest = z.infer<typeof CreateListingRequestSchema>;

packages/spec/src/identity/identity.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import {
44
AccountSchema,
55
SessionSchema,
66
VerificationTokenSchema,
7+
ApiKeySchema,
78
type User,
89
type Account,
910
type Session,
1011
type VerificationToken,
12+
type ApiKey,
1113
} from "./identity.zod";
1214

1315
describe('UserSchema', () => {
@@ -247,6 +249,78 @@ describe('VerificationTokenSchema', () => {
247249
});
248250
});
249251

252+
describe('ApiKeySchema', () => {
253+
it('should accept minimal API key', () => {
254+
const key = {
255+
id: 'key_123',
256+
name: 'CI/CD Pipeline',
257+
userId: 'user_123',
258+
createdAt: new Date().toISOString(),
259+
updatedAt: new Date().toISOString(),
260+
};
261+
262+
const result = ApiKeySchema.parse(key);
263+
expect(result.enabled).toBe(true);
264+
});
265+
266+
it('should accept full API key with rate limiting and permissions', () => {
267+
const key: ApiKey = {
268+
id: 'key_123',
269+
name: 'Production API Key',
270+
start: 'os_pk_ab',
271+
prefix: 'os_pk_',
272+
userId: 'user_123',
273+
organizationId: 'org_456',
274+
expiresAt: new Date(Date.now() + 86400000).toISOString(),
275+
createdAt: new Date().toISOString(),
276+
updatedAt: new Date().toISOString(),
277+
lastUsedAt: new Date().toISOString(),
278+
lastRefetchAt: new Date().toISOString(),
279+
enabled: true,
280+
rateLimitEnabled: true,
281+
rateLimitTimeWindow: 60000,
282+
rateLimitMax: 100,
283+
remaining: 95,
284+
permissions: { 'publish': true, 'read': true, 'manage': false },
285+
scopes: ['marketplace:publish', 'marketplace:read'],
286+
metadata: { environment: 'production' },
287+
};
288+
289+
const result = ApiKeySchema.parse(key);
290+
expect(result.organizationId).toBe('org_456');
291+
expect(result.scopes).toHaveLength(2);
292+
expect(result.permissions?.publish).toBe(true);
293+
});
294+
295+
it('should accept API key without optional fields', () => {
296+
const key = {
297+
id: 'key_123',
298+
name: 'Minimal Key',
299+
userId: 'user_123',
300+
createdAt: new Date().toISOString(),
301+
updatedAt: new Date().toISOString(),
302+
};
303+
304+
const result = ApiKeySchema.parse(key);
305+
expect(result.organizationId).toBeUndefined();
306+
expect(result.expiresAt).toBeUndefined();
307+
expect(result.scopes).toBeUndefined();
308+
});
309+
310+
it('should correctly infer ApiKey type', () => {
311+
const key: ApiKey = {
312+
id: 'key_123',
313+
name: 'Test Key',
314+
userId: 'user_123',
315+
createdAt: new Date().toISOString(),
316+
updatedAt: new Date().toISOString(),
317+
};
318+
319+
expect(key.id).toBe('key_123');
320+
expect(key.name).toBe('Test Key');
321+
});
322+
});
323+
250324
describe('Type inference', () => {
251325
it('should correctly infer User type', () => {
252326
const user: User = {

0 commit comments

Comments
 (0)