UNPKG

@schoolai/spicedb-zed-schema-parser

Version:

SpiceDB .zed file format parser and analyzer written in Typescript

1 lines 121 kB
{"version":3,"sources":["../src/index.ts","../src/builder/check.ts","../src/builder/types.ts","../src/builder/delete.ts","../src/builder/lookup.ts","../src/builder/query.ts","../src/builder/transaction.ts","../src/builder/write.ts","../src/builder/operations.ts","../src/generate-sdk.ts","../src/semantic-analyzer/dependency-graph.ts","../src/semantic-analyzer/symbol-table.ts","../src/semantic-analyzer/type-inference.ts","../src/semantic-analyzer/analyzer.ts","../src/schema-parser/parser.ts"],"sourcesContent":["export {\n createPermissions,\n Operations,\n PermissionOperations,\n Permissions,\n} from './builder'\n\nexport * from './generate-sdk'\nexport * from './semantic-analyzer/analyzer'\nexport * from './schema-parser/parser'\n","import { v1 } from '@authzed/authzed-node'\nimport { Operation, parseReference, SpiceDBClient } from './types'\n\n/**\n * Operation for checking permissions\n */\nexport class CheckOperation implements Operation<boolean> {\n protected subjectRef?: string\n protected resourceRef?: string\n protected consistency?: v1.Consistency\n\n constructor(protected permission: string) {}\n\n subject(ref: string): this {\n this.subjectRef = ref\n return this\n }\n\n resource(ref: string): this {\n this.resourceRef = ref\n return this\n }\n\n withConsistency(token: string): this {\n this.consistency = v1.Consistency.create({\n requirement: {\n oneofKind: 'atLeastAsFresh',\n atLeastAsFresh: v1.ZedToken.create({ token }),\n },\n })\n return this\n }\n\n async execute(client: SpiceDBClient): Promise<boolean> {\n if (!this.subjectRef || !this.resourceRef) {\n throw new Error('Check operation requires both subject and resource')\n }\n\n const [subjectType, subjectId] = parseReference(this.subjectRef)\n const [resourceType, resourceId] = parseReference(this.resourceRef)\n\n const request = v1.CheckPermissionRequest.create({\n resource: v1.ObjectReference.create({\n objectType: resourceType,\n objectId: resourceId,\n }),\n permission: this.permission,\n subject: v1.SubjectReference.create({\n object: v1.ObjectReference.create({\n objectType: subjectType,\n objectId: subjectId,\n }),\n }),\n consistency: this.consistency,\n })\n\n const response = await client.checkPermission(request)\n\n return (\n response.permissionship ===\n v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION\n )\n }\n\n toJSON() {\n return {\n permission: this.permission,\n subject: this.subjectRef,\n resource: this.resourceRef,\n consistency: this.consistency,\n }\n }\n}\n\n/**\n * Bound version of CheckOperation\n */\nexport class BoundCheckOperation extends CheckOperation {\n constructor(\n private client: SpiceDBClient,\n permission: string,\n ) {\n super(permission)\n }\n\n async execute(): Promise<boolean> {\n return super.execute(this.client)\n }\n}\n","import { v1 } from '@authzed/authzed-node'\n\nexport type SpiceDBClient = v1.ZedPromiseClientInterface\n/**\n * Base interface for all operations\n */\nexport interface Operation<T> {\n execute(client: SpiceDBClient): Promise<T>\n}\n\n/**\n * Helper function to parse resource references\n */\nexport function parseReference(ref: string): [string, string] {\n const [part1, part2] = ref.split(':')\n if (!part1 || !part2) {\n throw new Error(\n `Invalid reference format: ${ref}. Expected format: type:id`,\n )\n }\n return [part1, part2]\n}\n\n/**\n * Result types\n */\nexport interface QueryResult {\n type: string\n id: string\n relation?: string\n subjectType?: string\n subjectId?: string\n permissionship?: v1.LookupPermissionship\n}\n\nexport interface LookupResult {\n type: string\n id: string\n permissionship?: v1.LookupPermissionship\n}\n\nexport interface TransactionResult {\n token: string | null\n succeeded: boolean\n operationCount: number\n}\n","import { v1 } from '@authzed/authzed-node'\nimport { Operation, parseReference, SpiceDBClient } from './types'\n\n/**\n * Operation for deleting relationships\n */\nexport class DeleteOperation implements Operation<string | null> {\n protected filter: {\n subjectType?: string\n subjectId?: string\n relation?: string\n resourceType?: string\n resourceId?: string\n } = {}\n\n subject(ref: string): this {\n const [type, id] = parseReference(ref)\n this.filter.subjectType = type\n this.filter.subjectId = id\n return this\n }\n\n relation(rel: string): this {\n this.filter.relation = rel\n return this\n }\n\n resource(ref: string): this {\n const [type, id] = parseReference(ref)\n this.filter.resourceType = type\n this.filter.resourceId = id\n return this\n }\n\n where(filter: {\n resourceType?: string\n resourceId?: string\n relation?: string\n subjectType?: string\n subjectId?: string\n }): this {\n this.filter = { ...this.filter, ...filter }\n return this\n }\n\n async execute(client: SpiceDBClient): Promise<string | null> {\n const relationshipFilter: Partial<v1.RelationshipFilter> = {}\n\n if (this.filter.resourceType) {\n relationshipFilter.resourceType = this.filter.resourceType\n }\n if (this.filter.resourceId) {\n relationshipFilter.optionalResourceId = this.filter.resourceId\n }\n if (this.filter.relation) {\n relationshipFilter.optionalRelation = this.filter.relation\n }\n\n if (this.filter.subjectType || this.filter.subjectId) {\n const subjectFilter: Partial<v1.SubjectFilter> = {}\n if (this.filter.subjectType) {\n subjectFilter.subjectType = this.filter.subjectType\n }\n if (this.filter.subjectId) {\n subjectFilter.optionalSubjectId = this.filter.subjectId\n }\n relationshipFilter.optionalSubjectFilter =\n v1.SubjectFilter.create(subjectFilter)\n }\n\n const request = v1.DeleteRelationshipsRequest.create({\n relationshipFilter: v1.RelationshipFilter.create(relationshipFilter),\n })\n\n const response = await client.deleteRelationships(request)\n\n return response.deletedAt?.token || null\n }\n\n toJSON() {\n return {\n filter: this.filter,\n }\n }\n}\n\n/**\n * Bound version of DeleteOperation\n */\nexport class BoundDeleteOperation extends DeleteOperation {\n constructor(private client: SpiceDBClient) {\n super()\n }\n\n async execute(): Promise<string | null> {\n return super.execute(this.client)\n }\n}\n","import { v1 } from '@authzed/authzed-node'\nimport { LookupResult, Operation, parseReference, SpiceDBClient } from './types'\n\n/**\n * Lookup operation for finding accessible resources or subjects with permissions\n */\nexport class LookupOperation implements Operation<LookupResult[]> {\n protected lookupType?: 'resources' | 'subjects'\n protected resourceFilter?: { type: string; id?: string }\n protected subjectFilter?: { type: string; id?: string }\n protected permission?: string\n protected consistency?: v1.Consistency\n\n resourcesAccessibleBy(subjectRef: string): this {\n this.lookupType = 'resources'\n const [type, id] = parseReference(subjectRef)\n this.subjectFilter = { type, id }\n return this\n }\n\n subjectsWithAccessTo(resourceRef: string): this {\n this.lookupType = 'subjects'\n const [type, id] = parseReference(resourceRef)\n this.resourceFilter = { type, id }\n return this\n }\n\n ofType(type: string): this {\n if (this.lookupType === 'resources') {\n this.resourceFilter = { type }\n } else if (this.lookupType === 'subjects') {\n this.subjectFilter = { type }\n }\n return this\n }\n\n withPermission(permission: string): this {\n this.permission = permission\n return this\n }\n\n withConsistency(token: string): this {\n this.consistency = v1.Consistency.create({\n requirement: {\n oneofKind: 'atLeastAsFresh',\n atLeastAsFresh: v1.ZedToken.create({ token }),\n },\n })\n return this\n }\n\n async execute(client: SpiceDBClient): Promise<LookupResult[]> {\n if (!this.permission) {\n throw new Error('Lookup operation requires permission')\n }\n\n if (this.lookupType === 'resources' && this.subjectFilter) {\n const request = v1.LookupResourcesRequest.create({\n resourceObjectType: this.resourceFilter?.type || 'document',\n permission: this.permission,\n subject: v1.SubjectReference.create({\n object: v1.ObjectReference.create({\n objectType: this.subjectFilter.type,\n objectId: this.subjectFilter.id!,\n }),\n }),\n consistency: this.consistency,\n })\n\n const stream = await client.lookupResources(request)\n\n const results: LookupResult[] = []\n for (const result of stream) {\n if (result.resourceObjectId) {\n results.push({\n type: this.resourceFilter?.type || 'document',\n id: result.resourceObjectId,\n permissionship: result.permissionship,\n })\n }\n }\n return results\n }\n\n if (this.lookupType === 'subjects' && this.resourceFilter?.id) {\n const request = v1.LookupSubjectsRequest.create({\n resource: v1.ObjectReference.create({\n objectType: this.resourceFilter.type,\n objectId: this.resourceFilter.id,\n }),\n permission: this.permission,\n subjectObjectType: this.subjectFilter?.type || 'user',\n consistency: this.consistency,\n })\n\n const stream = await client.lookupSubjects(request)\n\n const results: LookupResult[] = []\n for (const result of stream) {\n if (result.subject?.subjectObjectId) {\n results.push({\n type: this.subjectFilter?.type || 'user',\n id: result.subject.subjectObjectId,\n permissionship: result.subject.permissionship,\n })\n }\n }\n return results\n }\n\n throw new Error('Invalid lookup configuration')\n }\n\n /**\n * Special helper for looking up subjects with multiple permission levels\n */\n async withPermissions(\n permissions: string[],\n client?: SpiceDBClient,\n ): Promise<Map<string, string>> {\n if (!this.resourceFilter?.id) {\n throw new Error('Multiple permission lookup requires a specific resource')\n }\n\n // This method needs a client, either passed in or error\n if (!client) {\n throw new Error(\n 'withPermissions requires a client. Use execute(client) or pass client as second parameter.',\n )\n }\n\n const resultMap = new Map<string, string>()\n\n // Query each permission level\n for (const permission of permissions) {\n const request = v1.LookupSubjectsRequest.create({\n resource: v1.ObjectReference.create({\n objectType: this.resourceFilter.type,\n objectId: this.resourceFilter.id,\n }),\n permission,\n subjectObjectType: this.subjectFilter?.type || 'user',\n consistency: this.consistency,\n })\n\n const stream = await client.lookupSubjects(request)\n\n for (const result of stream) {\n if (result.subject?.subjectObjectId) {\n // Only set if not already set (maintains hierarchy)\n if (!resultMap.has(result.subject.subjectObjectId)) {\n resultMap.set(result.subject.subjectObjectId, permission)\n }\n }\n }\n }\n\n return resultMap\n }\n\n toJSON() {\n return {\n lookupType: this.lookupType,\n resourceFilter: this.resourceFilter,\n subjectFilter: this.subjectFilter,\n permission: this.permission,\n consistency: this.consistency,\n }\n }\n}\n\n/**\n * Bound version of LookupOperation\n */\nexport class BoundLookupOperation extends LookupOperation {\n constructor(private client: SpiceDBClient) {\n super()\n }\n\n async execute(): Promise<LookupResult[]> {\n return super.execute(this.client)\n }\n\n async withPermissions(permissions: string[]): Promise<Map<string, string>> {\n return super.withPermissions(permissions, this.client)\n }\n}\n","import { v1 } from '@authzed/authzed-node'\nimport { Operation, parseReference, QueryResult, SpiceDBClient } from './types'\n\n/**\n * Operation for querying relationships\n */\nexport class QueryOperation implements Operation<QueryResult[]> {\n protected filter: {\n subjectType?: string\n subjectId?: string\n relation?: string\n resourceType?: string\n resourceId?: string\n } = {}\n protected queryType?: 'subjects' | 'resources'\n protected permission?: string\n protected consistency?: v1.Consistency\n\n subjects(type?: string): this {\n this.queryType = 'subjects'\n if (type) this.filter.subjectType = type\n return this\n }\n\n resources(type?: string): this {\n this.queryType = 'resources'\n if (type) this.filter.resourceType = type\n return this\n }\n\n subject(ref: string): this {\n if (ref.includes('*')) {\n const [type] = parseReference(ref)\n this.filter.subjectType = type\n } else {\n const [type, id] = parseReference(ref)\n this.filter.subjectType = type\n this.filter.subjectId = id\n }\n return this\n }\n\n relation(rel: string): this {\n if (rel !== '*') {\n this.filter.relation = rel\n }\n return this\n }\n\n resource(ref: string): this {\n if (ref.includes('*')) {\n const [type] = parseReference(ref)\n this.filter.resourceType = type\n } else {\n const [type, id] = parseReference(ref)\n this.filter.resourceType = type\n this.filter.resourceId = id\n }\n return this\n }\n\n withPermission(permission: string): this {\n this.permission = permission\n return this\n }\n\n withConsistency(token: string): this {\n this.consistency = v1.Consistency.create({\n requirement: {\n oneofKind: 'atLeastAsFresh',\n atLeastAsFresh: v1.ZedToken.create({ token }),\n },\n })\n return this\n }\n\n async execute(client: SpiceDBClient): Promise<QueryResult[]> {\n // If looking up subjects for a specific resource/permission\n if (\n this.queryType === 'subjects' &&\n this.filter.resourceType &&\n this.filter.resourceId &&\n this.permission\n ) {\n const request = v1.LookupSubjectsRequest.create({\n resource: v1.ObjectReference.create({\n objectType: this.filter.resourceType,\n objectId: this.filter.resourceId,\n }),\n permission: this.permission,\n subjectObjectType: this.filter.subjectType || 'user',\n consistency: this.consistency,\n })\n\n const stream = await client.lookupSubjects(request)\n\n const results: QueryResult[] = []\n for (const result of stream) {\n if (result.subject?.subjectObjectId) {\n results.push({\n type: this.filter.subjectType || 'user',\n id: result.subject.subjectObjectId,\n relation:\n result.subject.permissionship ===\n v1.LookupPermissionship.HAS_PERMISSION\n ? this.permission\n : undefined,\n })\n }\n }\n return results\n }\n\n // If looking up resources accessible to a subject\n if (\n this.queryType === 'resources' &&\n this.filter.subjectType &&\n this.filter.subjectId &&\n this.permission\n ) {\n const request = v1.LookupResourcesRequest.create({\n resourceObjectType: this.filter.resourceType || 'document',\n permission: this.permission,\n subject: v1.SubjectReference.create({\n object: v1.ObjectReference.create({\n objectType: this.filter.subjectType,\n objectId: this.filter.subjectId,\n }),\n }),\n consistency: this.consistency,\n })\n\n const stream = await client.lookupResources(request)\n\n const results: QueryResult[] = []\n for (const result of stream) {\n if (result.resourceObjectId) {\n results.push({\n type: this.filter.resourceType || 'document',\n id: result.resourceObjectId,\n permissionship: result.permissionship,\n })\n }\n }\n return results\n }\n\n // For general relationship queries, use ReadRelationships\n const filter: Partial<v1.RelationshipFilter> = {}\n\n if (this.filter.resourceType) {\n filter.resourceType = this.filter.resourceType\n }\n if (this.filter.resourceId) {\n filter.optionalResourceId = this.filter.resourceId\n }\n if (this.filter.relation) {\n filter.optionalRelation = this.filter.relation\n }\n\n if (this.filter.subjectType || this.filter.subjectId) {\n const subjectFilter: Partial<v1.SubjectFilter> = {}\n if (this.filter.subjectType) {\n subjectFilter.subjectType = this.filter.subjectType\n }\n if (this.filter.subjectId) {\n subjectFilter.optionalSubjectId = this.filter.subjectId\n }\n filter.optionalSubjectFilter = v1.SubjectFilter.create(subjectFilter)\n }\n\n const request = v1.ReadRelationshipsRequest.create({\n relationshipFilter: v1.RelationshipFilter.create(filter),\n consistency: this.consistency,\n })\n\n const stream = await client.readRelationships(request)\n const results: QueryResult[] = []\n\n for (const result of stream) {\n if (result.relationship) {\n results.push({\n type: result.relationship.resource?.objectType || '',\n id: result.relationship.resource?.objectId || '',\n relation: result.relationship.relation,\n subjectType: result.relationship.subject?.object?.objectType || '',\n subjectId: result.relationship.subject?.object?.objectId || '',\n })\n }\n }\n\n return results\n }\n\n toJSON() {\n return {\n queryType: this.queryType,\n filter: this.filter,\n permission: this.permission,\n consistency: this.consistency,\n }\n }\n}\n\n/**\n * Bound version of QueryOperation\n */\nexport class BoundQueryOperation extends QueryOperation {\n constructor(private client: SpiceDBClient) {\n super()\n }\n\n async execute(): Promise<QueryResult[]> {\n return super.execute(this.client)\n }\n}\n","import { v1 } from '@authzed/authzed-node'\nimport {\n Operation,\n parseReference,\n SpiceDBClient,\n TransactionResult,\n} from './types'\n\n/**\n * Transaction for batching multiple operations\n */\nexport class Transaction implements Operation<TransactionResult> {\n protected operations: (() => v1.RelationshipUpdate)[] = []\n\n grant(relation: string): TransactionWriteOperation<Transaction> {\n return new TransactionWriteOperation(this, 'grant', relation)\n }\n\n revoke(relation: string): TransactionWriteOperation<Transaction> {\n return new TransactionWriteOperation(this, 'revoke', relation)\n }\n\n add(operation: () => v1.RelationshipUpdate): this {\n this.operations.push(operation)\n return this\n }\n\n async execute(client: SpiceDBClient): Promise<TransactionResult> {\n const updates = this.operations.map(op => op())\n\n const request = v1.WriteRelationshipsRequest.create({ updates })\n const response = await client.writeRelationships(request)\n\n return {\n token: response.writtenAt?.token || null,\n succeeded: true,\n operationCount: updates.length,\n }\n }\n\n toJSON() {\n return {\n operationCount: this.operations.length,\n }\n }\n}\n\n/**\n * Bound version of Transaction\n */\nexport class BoundTransaction extends Transaction {\n constructor(private client: SpiceDBClient) {\n super()\n }\n\n grant(relation: string): TransactionWriteOperation<BoundTransaction> {\n return new TransactionWriteOperation(this, 'grant', relation)\n }\n\n revoke(relation: string): TransactionWriteOperation<BoundTransaction> {\n return new TransactionWriteOperation(this, 'revoke', relation)\n }\n\n async execute(): Promise<TransactionResult> {\n return super.execute(this.client)\n }\n\n async commit(): Promise<TransactionResult> {\n return this.execute()\n }\n}\n\n/**\n * Write operation within a transaction\n */\nexport class TransactionWriteOperation<T extends Transaction> {\n private subjects: string[] = []\n private resources: string[] = []\n\n constructor(\n private transaction: T,\n private operation: 'grant' | 'revoke',\n private relation: string,\n ) {}\n\n subject(ref: string | string[]): this {\n this.subjects = Array.isArray(ref) ? ref : [ref]\n return this\n }\n\n resource(ref: string | string[]): this {\n this.resources = Array.isArray(ref) ? ref : [ref]\n return this\n }\n\n and(): T {\n // Add all combinations to transaction\n for (const subjectRef of this.subjects) {\n for (const resourceRef of this.resources) {\n this.transaction.add(() => {\n const [subjectType, subjectId] = parseReference(subjectRef)\n const [resourceType, resourceId] = parseReference(resourceRef)\n\n const relationship = v1.Relationship.create({\n resource: v1.ObjectReference.create({\n objectType: resourceType,\n objectId: resourceId,\n }),\n relation: this.relation,\n subject: v1.SubjectReference.create({\n object: v1.ObjectReference.create({\n objectType: subjectType,\n objectId: subjectId,\n }),\n }),\n })\n\n return v1.RelationshipUpdate.create({\n relationship,\n operation:\n this.operation === 'grant'\n ? v1.RelationshipUpdate_Operation.TOUCH\n : v1.RelationshipUpdate_Operation.DELETE,\n })\n })\n }\n }\n\n return this.transaction\n }\n}\n","import { v1 } from '@authzed/authzed-node'\nimport { Operation, parseReference, SpiceDBClient } from './types'\n\n/**\n * Base class for operations that write relationships\n */\nexport class WriteOperation implements Operation<string | null> {\n protected subjects: string[] = []\n protected resources: string[] = []\n protected consistency?: v1.Consistency\n\n constructor(\n protected operation: 'grant' | 'revoke',\n protected relation: string,\n ) {}\n\n subject(ref: string | string[]): this {\n this.subjects = Array.isArray(ref) ? ref : [ref]\n return this\n }\n\n resource(ref: string | string[]): this {\n this.resources = Array.isArray(ref) ? ref : [ref]\n return this\n }\n\n withConsistency(token: string): this {\n this.consistency = v1.Consistency.create({\n requirement: {\n oneofKind: 'atLeastAsFresh',\n atLeastAsFresh: v1.ZedToken.create({ token }),\n },\n })\n return this\n }\n\n async execute(client: SpiceDBClient): Promise<string | null> {\n const updates: v1.RelationshipUpdate[] = []\n\n for (const subjectRef of this.subjects) {\n for (const resourceRef of this.resources) {\n const [subjectType, subjectId] = parseReference(subjectRef)\n const [resourceType, resourceId] = parseReference(resourceRef)\n\n const relationship = v1.Relationship.create({\n resource: v1.ObjectReference.create({\n objectType: resourceType,\n objectId: resourceId,\n }),\n relation: this.relation,\n subject: v1.SubjectReference.create({\n object: v1.ObjectReference.create({\n objectType: subjectType,\n objectId: subjectId,\n }),\n }),\n })\n\n updates.push(\n v1.RelationshipUpdate.create({\n relationship,\n operation:\n this.operation === 'grant'\n ? v1.RelationshipUpdate_Operation.TOUCH\n : v1.RelationshipUpdate_Operation.DELETE,\n }),\n )\n }\n }\n\n const request = v1.WriteRelationshipsRequest.create({ updates })\n const response = await client.writeRelationships(request)\n\n return response.writtenAt?.token || null\n }\n\n /**\n * Convert to a plain object for serialization\n */\n toJSON() {\n return {\n operation: this.operation,\n relation: this.relation,\n subjects: this.subjects,\n resources: this.resources,\n consistency: this.consistency,\n }\n }\n}\n\n/**\n * Bound version of WriteOperation with immediate execute\n */\nexport class BoundWriteOperation extends WriteOperation {\n constructor(\n private client: SpiceDBClient,\n operation: 'grant' | 'revoke',\n relation: string,\n ) {\n super(operation, relation)\n }\n\n async execute(): Promise<string | null> {\n return super.execute(this.client)\n }\n}\n","import { BoundCheckOperation, CheckOperation } from './check'\nimport { BoundDeleteOperation, DeleteOperation } from './delete'\nimport { BoundLookupOperation, LookupOperation } from './lookup'\nimport { BoundQueryOperation, QueryOperation } from './query'\nimport { BoundTransaction, Transaction } from './transaction'\nimport { Operation, SpiceDBClient } from './types'\nimport { BoundWriteOperation, WriteOperation } from './write'\n\n/**\n * Static builders for creating pure operations without a client\n */\n// biome-ignore lint/complexity/noStaticOnlyClass: builder class is intended\nexport class PermissionOperations {\n /**\n * Create a grant operation\n */\n static grant(relation: string): WriteOperation {\n return new WriteOperation('grant', relation)\n }\n\n /**\n * Create a revoke operation\n */\n static revoke(relation: string): WriteOperation {\n return new WriteOperation('revoke', relation)\n }\n\n /**\n * Create a check operation\n */\n static check(permission: string): CheckOperation {\n return new CheckOperation(permission)\n }\n\n /**\n * Create a find/query operation\n */\n static find(): QueryOperation {\n return new QueryOperation()\n }\n\n /**\n * Create a delete operation\n */\n static delete(): DeleteOperation {\n return new DeleteOperation()\n }\n\n /**\n * Create a batch transaction\n */\n static batch(): Transaction {\n return new Transaction()\n }\n\n /**\n * Create a lookup operation\n */\n static lookup(): LookupOperation {\n return new LookupOperation()\n }\n}\n\n/**\n * Main entry point for the permissions DSL with bound client\n */\nexport class Permissions {\n constructor(private client: SpiceDBClient) {}\n\n /**\n * Grant a relation between subjects and resources\n */\n grant(relation: string): BoundWriteOperation {\n return new BoundWriteOperation(this.client, 'grant', relation)\n }\n\n /**\n * Revoke a relation between subjects and resources\n */\n revoke(relation: string): BoundWriteOperation {\n return new BoundWriteOperation(this.client, 'revoke', relation)\n }\n\n /**\n * Check if a subject has a permission on a resource\n */\n check(permission: string): BoundCheckOperation {\n return new BoundCheckOperation(this.client, permission)\n }\n\n /**\n * Find subjects or resources matching criteria\n */\n find(): BoundQueryOperation {\n return new BoundQueryOperation(this.client)\n }\n\n /**\n * Delete relationships matching a filter\n */\n delete(): BoundDeleteOperation {\n return new BoundDeleteOperation(this.client)\n }\n\n /**\n * Create a batch transaction\n */\n batch(): BoundTransaction {\n return new BoundTransaction(this.client)\n }\n\n /**\n * Lookup resources accessible to a subject\n */\n lookup(): BoundLookupOperation {\n return new BoundLookupOperation(this.client)\n }\n\n /**\n * Execute a pure operation with this instance's client\n */\n async execute<T>(operation: Operation<T>): Promise<T> {\n return operation.execute(this.client)\n }\n}\n\n/**\n * Create a permissions instance with bound client\n */\nexport function createPermissions(client: SpiceDBClient): Permissions {\n return new Permissions(client)\n}\n\n/**\n * Export the static builders for convenience\n */\nexport const Operations = PermissionOperations\n","import {\n AugmentedObjectTypeDefinition,\n AugmentedSchemaAST,\n} from './semantic-analyzer/types'\n\n/**\n * Generates the TypeScript code for a permissions SDK based on a zed schema.\n * @param schema The augmented schema AST.\n * @returns The generated TypeScript code as a string.\n */\nexport function generateSDK(\n schema: AugmentedSchemaAST,\n parserImport = '@schoolai/spicedb-zed-schema-parser',\n): string {\n const objectDefs = schema.definitions.filter(\n (def): def is AugmentedObjectTypeDefinition => def.type === 'definition',\n )\n\n let code = `// Generated by @schoolai/spicedb-zed-schema-parser\n// Do not edit manually.\n\nimport { PermissionOperations } from '${parserImport}';\n\n// --------------- GENERIC TYPES ---------------\n\nexport type Subject<T extends string> = \\`\\${T}:\\${string}\\`;\nexport type Resource<T extends string> = \\`\\${T}:\\${string}\\`;\n\n// --------------- RESOURCE TYPES ---------------\n`\n\n for (const def of objectDefs) {\n code += `export type ${toPascalCase(def.name)}Resource = Resource<'${def.name}'>;\\n`\n }\n\n code += '\\nexport const permissions = {\\n'\n\n for (const def of objectDefs) {\n if (def.relations.length === 0 && def.permissions.length === 0) {\n continue\n }\n code += ` ${toCamelCase(def.name)}: {\\n`\n // Generate grant/revoke operations\n code += generateGrantRevoke(def)\n // Generate check operations\n code += generateCheck(def)\n // Generate find operations\n code += generateFind(def)\n code += ` },\\n`\n }\n\n code += '};\\n'\n\n return code\n}\n\nfunction generateGrantRevoke(def: AugmentedObjectTypeDefinition): string {\n const resourceType = `${toPascalCase(def.name)}Resource`\n let code = ' grant: {\\n'\n for (const rel of def.relations) {\n const subjectTypeLiterals = [\n ...new Set(rel.types.map(t => `'${t.typeName}'`)),\n ].join(' | ')\n const subjectTypes = `Subject<${subjectTypeLiterals || 'never'}>`\n code += ` ${toCamelCase(rel.name)}: (subject: ${subjectTypes}, resource: ${resourceType}) => PermissionOperations.grant('${rel.name}').subject(subject).resource(resource),\\n`\n }\n code += ' },\\n'\n code += ' revoke: {\\n'\n for (const rel of def.relations) {\n const subjectTypeLiterals = [\n ...new Set(rel.types.map(t => `'${t.typeName}'`)),\n ].join(' | ')\n const subjectTypes = `Subject<${subjectTypeLiterals || 'never'}>`\n code += ` ${toCamelCase(rel.name)}: (subject: ${subjectTypes}, resource: ${resourceType}) => PermissionOperations.revoke('${rel.name}').subject(subject).resource(resource),\\n`\n }\n code += ' },\\n'\n return code\n}\n\nfunction generateFind(def: AugmentedObjectTypeDefinition): string {\n let code = ' find: {\\n'\n for (const rel of def.relations) {\n const pascalRel = toPascalCase(rel.name)\n const subjectTypeLiterals = [\n ...new Set(rel.types.map(t => `'${t.typeName}'`)),\n ].join(' | ')\n const subjectTypes = `Subject<${subjectTypeLiterals || 'never'}>`\n code += ` by${pascalRel}: (subject: ${subjectTypes}) => PermissionOperations.find().relation('${rel.name}').subject(subject),\\n`\n }\n code += ' },\\n'\n return code\n}\n\nfunction generateCheck(def: AugmentedObjectTypeDefinition): string {\n const resourceType = `${toPascalCase(def.name)}Resource`\n let code = ' check: {\\n'\n for (const perm of def.permissions) {\n const subjectTypeLiterals = [\n ...new Set(perm.inferredSubjectTypes?.map(t => `'${t.typeName}'`)),\n ].join(' | ')\n const subjectTypes = `Subject<${subjectTypeLiterals || 'never'}>`\n code += ` ${toCamelCase(perm.name)}: (subject: ${subjectTypes}, resource: ${resourceType}) => PermissionOperations.check('${perm.name}').subject(subject).resource(resource),\\n`\n }\n code += ' },\\n'\n return code\n}\n\nfunction toPascalCase(name: string): string {\n return name\n .split(/[_\\-\\s]+/)\n .filter(Boolean)\n .map(word => word.charAt(0).toUpperCase() + word.slice(1))\n .join('')\n}\n\nfunction toCamelCase(name: string): string {\n if (name.length === 0) return name\n return (\n toPascalCase(name).charAt(0).toLowerCase() + toPascalCase(name).slice(1)\n )\n}\n","interface DependencyNode {\n type: string\n name: string\n fullName: string\n}\n\nexport class DependencyGraph {\n private adjacencyList: Map<string, Set<string>> = new Map()\n private nodes: Map<string, DependencyNode> = new Map()\n\n addNode(node: DependencyNode): void {\n this.nodes.set(node.fullName, node)\n if (!this.adjacencyList.has(node.fullName)) {\n this.adjacencyList.set(node.fullName, new Set())\n }\n }\n\n addEdge(from: string, to: string): void {\n if (!this.adjacencyList.has(from)) {\n this.adjacencyList.set(from, new Set())\n }\n this.adjacencyList.get(from)!.add(to)\n }\n\n // Find cycles via depth-first search\n findCycles(): string[][] {\n const cycles: string[][] = []\n const visited = new Set<string>()\n const recursionStack = new Set<string>()\n const path: string[] = []\n\n const dfs = (node: string): void => {\n visited.add(node)\n recursionStack.add(node)\n path.push(node)\n\n const neighbors = this.adjacencyList.get(node) || new Set()\n for (const neighbor of neighbors) {\n if (!visited.has(neighbor)) {\n dfs(neighbor)\n } else if (recursionStack.has(neighbor)) {\n // Found a cycle\n const cycleStart = path.indexOf(neighbor)\n cycles.push(path.slice(cycleStart).concat(neighbor))\n }\n }\n\n path.pop()\n recursionStack.delete(node)\n }\n\n for (const node of this.adjacencyList.keys()) {\n if (!visited.has(node)) {\n dfs(node)\n }\n }\n\n return cycles\n }\n}\n","import {\n ObjectTypeDefinition,\n CaveatDefinition,\n RelationDeclaration,\n PermissionDeclaration,\n} from '../schema-parser/parser'\n\ninterface SymbolInfo {\n type: 'definition' | 'caveat'\n definition: ObjectTypeDefinition | CaveatDefinition\n relations: Map<string, RelationDeclaration>\n permissions: Map<string, PermissionDeclaration>\n}\n\nexport class SymbolTable {\n private symbols: Map<string, SymbolInfo> = new Map()\n\n addDefinition(def: ObjectTypeDefinition | CaveatDefinition): void {\n const info: SymbolInfo = {\n type: def.type === 'definition' ? 'definition' : 'caveat',\n definition: def,\n relations: new Map(),\n permissions: new Map(),\n }\n\n if (def.type === 'definition') {\n for (const rel of def.relations) {\n info.relations.set(rel.name, rel)\n }\n for (const perm of def.permissions) {\n info.permissions.set(perm.name, perm)\n }\n }\n\n this.symbols.set(def.name, info)\n }\n\n getDefinition(name: string): SymbolInfo | undefined {\n return this.symbols.get(name)\n }\n\n hasDefinition(name: string): boolean {\n return this.symbols.has(name)\n }\n\n getAllDefinitions(): SymbolInfo[] {\n return Array.from(this.symbols.values())\n }\n\n getRelation(\n typeName: string,\n relationName: string,\n ): RelationDeclaration | undefined {\n const info = this.symbols.get(typeName)\n return info?.relations.get(relationName)\n }\n\n getPermission(\n typeName: string,\n permissionName: string,\n ): PermissionDeclaration | undefined {\n const info = this.symbols.get(typeName)\n return info?.permissions.get(permissionName)\n }\n\n hasRelationOrPermission(typeName: string, name: string): boolean {\n const info = this.symbols.get(typeName)\n if (!info) return false\n return info.relations.has(name) || info.permissions.has(name)\n }\n}\n","import { PermissionExpression, RelationType } from '../schema-parser/parser'\nimport { SymbolTable } from './symbol-table'\n\nexport class TypeInferenceEngine {\n constructor(private symbolTable: SymbolTable) {}\n\n inferExpressionType(\n defName: string,\n expr: PermissionExpression,\n callStack: Set<string> = new Set(),\n ): RelationType[] | null {\n switch (expr.type) {\n case 'identifier': {\n const rel = this.symbolTable.getRelation(defName, expr.name)\n if (rel) {\n const resolvedTypes: RelationType[] = []\n for (const typeRef of rel.types) {\n if (typeRef.relation) {\n const subjectRelationTypes = this.inferSubjectRelationType(\n typeRef.typeName,\n typeRef.relation,\n callStack,\n )\n if (subjectRelationTypes) {\n resolvedTypes.push(...subjectRelationTypes)\n }\n } else {\n resolvedTypes.push(typeRef)\n }\n }\n return this.deduplicateTypes(resolvedTypes)\n }\n const perm = this.symbolTable.getPermission(defName, expr.name)\n if (perm) {\n const stackKey = `${defName}#${expr.name}`\n if (callStack.has(stackKey)) {\n return null // Cycle detected\n }\n callStack.add(stackKey)\n const result = this.inferExpressionType(\n defName,\n perm.expression,\n callStack,\n )\n callStack.delete(stackKey)\n return result\n }\n return null\n }\n\n case 'union': {\n const allTypes: RelationType[] = []\n for (const operand of expr.operands) {\n const types = this.inferExpressionType(defName, operand, callStack)\n if (types) {\n allTypes.push(...types)\n }\n }\n return this.deduplicateTypes(allTypes)\n }\n\n case 'intersection': {\n let commonTypes: RelationType[] | null = null\n for (const operand of expr.operands) {\n const types = this.inferExpressionType(defName, operand, callStack)\n if (!types) return null\n if (!commonTypes) {\n commonTypes = types\n } else {\n commonTypes = this.intersectTypes(commonTypes, types)\n }\n }\n return commonTypes\n }\n\n case 'exclusion':\n return this.inferExpressionType(defName, expr.left, callStack)\n\n case 'arrow':\n case 'any':\n case 'all': {\n const leftTypes = this.inferExpressionType(\n defName,\n expr.left,\n callStack,\n )\n if (!leftTypes) return null\n\n const resultTypes: RelationType[] = []\n for (const leftType of leftTypes) {\n const targetRel = this.symbolTable.getRelation(\n leftType.typeName,\n expr.target,\n )\n if (targetRel) {\n for (const typeRef of targetRel.types) {\n if (typeRef.relation) {\n const subjectRelationTypes = this.inferSubjectRelationType(\n typeRef.typeName,\n typeRef.relation,\n callStack,\n )\n if (subjectRelationTypes) {\n resultTypes.push(...subjectRelationTypes)\n }\n } else {\n resultTypes.push(typeRef)\n }\n }\n }\n const targetPerm = this.symbolTable.getPermission(\n leftType.typeName,\n expr.target,\n )\n if (targetPerm) {\n const stackKey = `${leftType.typeName}#${expr.target}`\n if (callStack.has(stackKey)) {\n continue // Cycle detected, skip this path\n }\n callStack.add(stackKey)\n const permTypes = this.inferExpressionType(\n leftType.typeName,\n targetPerm.expression,\n callStack,\n )\n callStack.delete(stackKey)\n if (permTypes) {\n resultTypes.push(...permTypes)\n }\n }\n }\n return this.deduplicateTypes(resultTypes)\n }\n\n default:\n return null\n }\n }\n\n private inferSubjectRelationType(\n typeName: string,\n relationName: string,\n callStack: Set<string>,\n ): RelationType[] | null {\n const targetRel = this.symbolTable.getRelation(typeName, relationName)\n if (targetRel) {\n const resolvedTypes: RelationType[] = []\n for (const typeRef of targetRel.types) {\n if (typeRef.relation) {\n const subjectRelationTypes = this.inferSubjectRelationType(\n typeRef.typeName,\n typeRef.relation,\n callStack,\n )\n if (subjectRelationTypes) {\n resolvedTypes.push(...subjectRelationTypes)\n }\n } else {\n resolvedTypes.push(typeRef)\n }\n }\n return this.deduplicateTypes(resolvedTypes)\n }\n\n const targetPerm = this.symbolTable.getPermission(typeName, relationName)\n if (targetPerm) {\n const stackKey = `${typeName}#${relationName}`\n if (callStack.has(stackKey)) {\n return null // Cycle detected\n }\n callStack.add(stackKey)\n const result = this.inferExpressionType(\n typeName,\n targetPerm.expression,\n callStack,\n )\n callStack.delete(stackKey)\n return result\n }\n return null\n }\n\n private deduplicateTypes(types: RelationType[]): RelationType[] {\n const seen = new Set<string>()\n const result: RelationType[] = []\n\n for (const type of types) {\n const key = `${type.typeName}${type.wildcard ? ':*' : ''}${type.relation ? '#' + type.relation : ''}`\n if (!seen.has(key)) {\n seen.add(key)\n result.push(type)\n }\n }\n\n return result\n }\n\n private intersectTypes(a: RelationType[], b: RelationType[]): RelationType[] {\n const result: RelationType[] = []\n const bKeys = new Set(\n b.map(\n type =>\n `${type.typeName}${type.wildcard ? ':*' : ''}${type.relation ? '#' + type.relation : ''}`,\n ),\n )\n\n for (const typeA of a) {\n const keyA = `${typeA.typeName}${typeA.wildcard ? ':*' : ''}${typeA.relation ? '#' + typeA.relation : ''}`\n if (bKeys.has(keyA)) {\n result.push(typeA)\n }\n }\n // Deduplication is handled by the caller (inferExpressionType for 'intersection') if needed,\n // but intersecting already deduplicated lists should yield a deduplicated list.\n return this.deduplicateTypes(result) // Ensure the result is deduplicated\n }\n}\n","import {\n CaveatDefinition,\n ObjectTypeDefinition,\n PermissionExpression,\n RelationDeclaration,\n SchemaAST,\n} from '../schema-parser/parser'\nimport { DependencyGraph } from './dependency-graph'\nimport { SymbolTable } from './symbol-table'\nimport { TypeInferenceEngine } from './type-inference'\nimport {\n AugmentedObjectTypeDefinition,\n AugmentedPermissionDeclaration,\n AugmentedRelationDeclaration,\n AugmentedSchemaAST,\n SchemaAnalysisResult,\n SemanticError,\n} from './types'\n\n// These errors mean the fundamental structure or references are broken.\nconst criticalPreAugmentationErrorCodes = [\n 'DUPLICATE_DEFINITION',\n 'UNDEFINED_TYPE',\n 'UNDEFINED_RELATION',\n 'DUPLICATE_MEMBER_NAME',\n]\n\n// These errors mean the schema cannot be used for semantic analysis\nconst fatalForUsageErrorCodes = [\n ...criticalPreAugmentationErrorCodes, // Errors caught before augmentation\n 'CIRCULAR_DEPENDENCY', // Cycles make the graph unusable\n 'UNDEFINED_IDENTIFIER', // Referenced name doesn't exist\n 'UNDEFINED_ARROW_TARGET', // Arrow target doesn't exist on resolved types\n 'AUGMENTATION_INTERNAL_ERROR', // If augmentation process itself failed\n]\n\nexport class SemanticAnalyzer {\n private symbolTable: SymbolTable\n private typeInference: TypeInferenceEngine\n private errors: SemanticError[] = []\n private warnings: SemanticError[] = []\n\n constructor() {\n this.symbolTable = new SymbolTable()\n this.typeInference = new TypeInferenceEngine(this.symbolTable)\n }\n\n analyze(ast: SchemaAST): SchemaAnalysisResult {\n this.errors = []\n this.warnings = []\n // Re-initialize symbolTable and typeInference for each call to ensure a clean state.\n this.symbolTable = new SymbolTable()\n this.typeInference = new TypeInferenceEngine(this.symbolTable)\n\n // Phase 1: Build symbol table\n this.buildSymbolTable(ast)\n\n // Phase 2: Validate definitions (relations, types within them)\n this.validateDefinitions(ast)\n\n // Phase 3: Check for cycles using the original AST structure\n this.checkForCycles(ast)\n\n // Phase 4: Validate expressions using the original AST structure\n this.validateExpressions(ast)\n\n // Phase 5: Additional checks\n this.performAdditionalChecks(ast)\n\n const isValid = this.errors.length === 0\n const isFatalForUsage = this.errors.some(e =>\n fatalForUsageErrorCodes.includes(e.code),\n )\n\n const augmentedAst = this.augmentAst(ast)\n\n if (isFatalForUsage) {\n // If any \"fatal for usage\" errors exist, the augmented AST is not reliable,\n // even if it was partially built or built before the error was detected.\n return {\n augmentedAst: undefined,\n symbolTable: this.symbolTable, // Always return the symbol table\n errors: this.errors,\n warnings: this.warnings,\n isValid: isValid,\n }\n }\n\n return {\n augmentedAst,\n symbolTable: this.symbolTable, // Always return the symbol table\n errors: this.errors,\n warnings: this.warnings,\n isValid: isValid,\n }\n }\n\n private augmentAst(ast: SchemaAST): AugmentedSchemaAST | undefined {\n try {\n const augmentedDefinitions: (\n | AugmentedObjectTypeDefinition\n | CaveatDefinition\n )[] = []\n for (const def of ast.definitions) {\n if (def.type === 'definition') {\n // Map relations to AugmentedRelationDeclaration\n // For now, AugmentedRelationDeclaration is structurally identical to RelationDeclaration\n const augmentedRelations: AugmentedRelationDeclaration[] =\n def.relations.map(rel => ({\n ...rel,\n }))\n\n // Map permissions to AugmentedPermissionDeclaration, inferring types\n const augmentedPermissions: AugmentedPermissionDeclaration[] = []\n for (const perm of def.permissions) {\n const inferredTypes = this.typeInference.inferExpressionType(\n def.name,\n perm.expression,\n )\n augmentedPermissions.push({\n ...perm,\n inferredSubjectTypes: inferredTypes,\n })\n }\n\n const augmentedDef: AugmentedObjectTypeDefinition = {\n type: 'definition', // Explicitly set type\n name: def.name,\n relations: augmentedRelations,\n permissions: augmentedPermissions,\n // Safely access comments, assuming ObjectTypeDefinition might have 'comments'\n // even if not in its strict imported type, consistent with BaseNode expectation.\n comments: (def as ObjectTypeDefinition & { comments?: string[] })\n .comments,\n }\n augmentedDefinitions.push(augmentedDef)\n } else if (def.type === 'caveat') {\n // CaveatDefinitions are included as-is in the augmented AST\n augmentedDefinitions.push(def)\n }\n }\n return { definitions: augmentedDefinitions }\n } catch (e: any) {\n // Catch unexpected errors during the augmentation process itself\n this.addError(\n 'AUGMENTATION_INTERNAL_ERROR',\n `Internal error during AST augmentation: ${e.message}`,\n {},\n )\n return undefined // Ensure AST is undefined if augmentation crashes\n }\n }\n\n // Phase 1: Build symbol table\n private buildSymbolTable(ast: SchemaAST): void {\n const definedNames = new Set<string>()\n\n for (const def of ast.definitions) {\n // Check for duplicate definitions\n if (definedNames.has(def.name)) {\n this.addError(\n 'DUPLICATE_DEFINITION',\n `Duplicate definition name: ${def.name}`,\n { definition: def.name },\n )\n }\n definedNames.add(def.name)\n\n // Add to symbol table\n this.symbolTable.addDefinition(def)\n }\n }\n\n // Phase 2: Validate definitions\n private validateDefinitions(ast: SchemaAST): void {\n for (const def of ast.definitions) {\n if (def.type === 'definition') {\n this.validateObjectTypeDefinition(def as ObjectTypeDefinition)\n } else if (def.type === 'caveat') {\n this.validateCaveatDefinition(def as CaveatDefinition)\n }\n }\n }\n\n private validateObjectTypeDefinition(def: ObjectTypeDefinition): void {\n // Check for duplicate relation/permission names\n const names = new Set<string>()\n\n for (const rel of def.relations) {\n if (names.has(rel.name)) {\n this.addError(\n 'DUPLICATE_MEMBER_NAME',\n `Duplicate relation/permission name '${rel.name}' in ${def.name}`,\n { definition: def.name, relation: rel.name },\n )\n }\n names.add(rel.name)\n this.validateRelation(def.name, rel)\n }\n\n for (const perm of def.permissions) {\n if (names.has(perm.name)) {\n this.addError(\n 'DUPLICATE_MEMBER_NAME'