From 2c4ed59acba03054f356c53cc6df2da4c66bb282 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 11 Oct 2026 16:25:15 +1100 Subject: [PATCH 1/2] add back in origin as an input option --- .../AccessControlModal.styles.css | 6 +- .../AccessControlModal.ts | 72 ++++++++++++---- .../access-control-modal/helpers.ts | 22 ++++- src/components/access-control-modal/types.ts | 7 +- test/components/access-control-modal.test.ts | 85 +++++++++++++++++++ 5 files changed, 168 insertions(+), 24 deletions(-) diff --git a/src/components/access-control-modal/AccessControlModal.styles.css b/src/components/access-control-modal/AccessControlModal.styles.css index cb0d0c17b..94fa1b66c 100644 --- a/src/components/access-control-modal/AccessControlModal.styles.css +++ b/src/components/access-control-modal/AccessControlModal.styles.css @@ -189,7 +189,7 @@ } } - .access-grants-image--group { + .access-grants-image--agentGroup { background: var(--solid-ui-color-blue-900, #083575); } @@ -198,6 +198,10 @@ background: var(--solid-ui-color-lavender-300, #e6dcff); } + .access-grants-image--origin { + background: var(--solid-ui-color-green-700, #15803D); + } + .access-grants-image--unknown { background: var(--solid-ui-color-gray-300, #D1D5DB); } diff --git a/src/components/access-control-modal/AccessControlModal.ts b/src/components/access-control-modal/AccessControlModal.ts index b6f30e48d..c71228661 100644 --- a/src/components/access-control-modal/AccessControlModal.ts +++ b/src/components/access-control-modal/AccessControlModal.ts @@ -26,7 +26,7 @@ import '@/components/combobox-option' import styles from './AccessControlModal.styles.css' import type { ComboboxChangeEvent } from '@/components/combobox' import type { AccessChange, AccessControlBadge, AccessGrantEntry, AuthorizationSubjectSet, ChangedAccessGrant, PendingAccessGrant } from './types' -import { dedupeByKey, dedupeComboboxOptions, isHttpUri, type StringComboboxOptionData } from './helpers' +import { dedupeByKey, dedupeComboboxOptions, isAbsoluteHttpUri, isOriginUrl, type StringComboboxOptionData } from './helpers' function isDefined (value: T | undefined | null): value is T { return value !== undefined && value !== null @@ -188,7 +188,7 @@ export default class AccessControlModal extends WebComponent { const agentGroup = authorization.agentGroup[0] if (agentGroup) { return { - kind: 'group' as const, + kind: 'agentGroup' as const, text: this.getInitials(resolvedSubjectLabel, 1) } } @@ -203,6 +203,14 @@ export default class AccessControlModal extends WebComponent { } } + const origin = authorization.origin[0] + if (origin) { + return { + kind: 'origin' as const, + text: 'O' + } + } + return { kind: 'unknown' as const, text: '?' @@ -228,6 +236,7 @@ export default class AccessControlModal extends WebComponent { ...authorization.agent, ...authorization.agentGroup, ...authorization.agentClass, + ...authorization.origin, ] } @@ -753,7 +762,8 @@ export default class AccessControlModal extends WebComponent { const subjectSets: AuthorizationSubjectSet[] = [ { type: 'agent', iris: authorization.agent }, { type: 'agentGroup', iris: authorization.agentGroup }, - { type: 'agentClass', iris: authorization.agentClass } + { type: 'agentClass', iris: authorization.agentClass }, + { type: 'origin', iris: authorization.origin } ] return subjectSets.flatMap(({ type, iris }) => iris.map(iri => ({ type, iri }))) @@ -846,8 +856,11 @@ export default class AccessControlModal extends WebComponent { ): Promise { try { const normalizedPrinciple = principle.trim() - const resolvedPrinciple = await solidLogicSingleton.acl.classifyAccessControlSubject(normalizedPrinciple) - const fallbackKind = this.isHttpUri(principle) ? 'agent' : undefined + const normalizedOrigin = this.normalizeOriginInput(normalizedPrinciple) + const resolvedPrinciple = normalizedOrigin + ? { kind: 'origin' as const, subjectValue: normalizedOrigin } + : await solidLogicSingleton.acl.classifyAccessControlSubject(normalizedPrinciple) + const fallbackKind = isAbsoluteHttpUri(principle) ? 'agent' : undefined const subjectType = resolvedPrinciple?.kind ?? fallbackKind const subjectValue = resolvedPrinciple?.subjectValue ?? normalizedPrinciple @@ -858,13 +871,6 @@ export default class AccessControlModal extends WebComponent { } } - if (subjectType === 'origin') { - return { - principle, - error: 'Origin access grants are not supported yet' - } - } - const label = preferredLabel ?? await this.resolvePendingAccessGrantLabel(subjectValue, principle) return { @@ -945,14 +951,50 @@ export default class AccessControlModal extends WebComponent { } private async getPreferredAccessPrincipleOptions (query: string): Promise { - if (!isHttpUri(query)) { + const normalizedOrigin = this.normalizeOriginInput(query) + if (normalizedOrigin) { + return [{ + label: `Use origin ${normalizedOrigin}`, + value: normalizedOrigin + }] + } + + if (!isAbsoluteHttpUri(query)) { return [] } + if (isOriginUrl(query)) { + return [{ + label: `Use origin ${new URL(query).origin}`, + value: new URL(query).origin + }] + } + const urlOption = await this.createUrlOption(query) return dedupeComboboxOptions(urlOption ? [urlOption] : []) } + private normalizeOriginInput (value: string): string | undefined { + const trimmedValue = value.trim() + if (!trimmedValue || isAbsoluteHttpUri(trimmedValue)) { + return undefined + } + + try { + const parsed = new URL(`https://${trimmedValue}`) + const isHttpOrigin = parsed.protocol === 'http:' || parsed.protocol === 'https:' + const hasOnlyOriginParts = parsed.pathname === '/' && !parsed.search && !parsed.hash + const isRecognizedHost = parsed.hostname.includes('.') || + parsed.hostname.includes(':') + + return isHttpOrigin && hasOnlyOriginParts && isRecognizedHost && !parsed.username && !parsed.password + ? parsed.origin + : undefined + } catch { + return undefined + } + } + private async createUrlOption (uri: string): Promise { try { await solidLogicSingleton.store.fetcher.load(sym(uri).doc()) @@ -1003,10 +1045,6 @@ export default class AccessControlModal extends WebComponent { return nothing } - private isHttpUri (value: string): boolean { - return value.startsWith('http://') || value.startsWith('https://') - } - private async onCopyLinkClick (event: Event) { event.preventDefault() diff --git a/src/components/access-control-modal/helpers.ts b/src/components/access-control-modal/helpers.ts index 6a6b77f91..53cf25ff6 100644 --- a/src/components/access-control-modal/helpers.ts +++ b/src/components/access-control-modal/helpers.ts @@ -2,8 +2,26 @@ import type { ComboboxOptionData } from '@/components/combobox' export type StringComboboxOptionData = ComboboxOptionData & { value: string } -export function isHttpUri (possibleUri: string): boolean { - return possibleUri.startsWith('http://') || possibleUri.startsWith('https://') +export function isAbsoluteHttpUri (value: string): boolean { + try { + const url = new URL(value) + return (url.protocol === 'http:' || url.protocol === 'https:') && Boolean(url.hostname) + } catch { + return false + } +} + +export function isOriginUrl (value: string): boolean { + if (!isAbsoluteHttpUri(value)) { + return false + } + + const url = new URL(value) + return url.pathname === '/' && + !url.search && + !url.hash && + !url.username && + !url.password } export function dedupeByKey (items: T[], getKey: (item: T) => string | undefined): T[] { diff --git a/src/components/access-control-modal/types.ts b/src/components/access-control-modal/types.ts index 887b4a8fb..96663ccd7 100644 --- a/src/components/access-control-modal/types.ts +++ b/src/components/access-control-modal/types.ts @@ -1,7 +1,6 @@ import type { AccessControlSubjectKind, AccessRole, AccessSubject, Authorization } from 'solid-logic' -export type AccessControlBadgeKind = 'agent' | 'group' | 'agentClass' | 'unknown' -export type PendingAccessSubjectKind = Exclude +export type AccessControlBadgeKind = AccessControlSubjectKind | 'unknown' export type AccessControlBadge = { kind: AccessControlBadgeKind @@ -11,7 +10,7 @@ export type AccessControlBadge = { export type PendingAccessGrant = { id?: string - subjectType: PendingAccessSubjectKind + subjectType: AccessControlSubjectKind subjectValue: string role: AccessRole label: string @@ -32,7 +31,7 @@ export type ChangedAccessGrant = { } export type AuthorizationSubjectSet = { - type: PendingAccessSubjectKind + type: AccessControlSubjectKind iris: string[] } diff --git a/test/components/access-control-modal.test.ts b/test/components/access-control-modal.test.ts index 3f7928999..dbff6df18 100644 --- a/test/components/access-control-modal.test.ts +++ b/test/components/access-control-modal.test.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from 'vitest' +import { isAbsoluteHttpUri, isOriginUrl } from '../../src/components/access-control-modal/helpers' const planGrant = vi.fn() const planRevoke = vi.fn() @@ -61,6 +62,41 @@ describe('AccessControlModal submit', () => { await import('../../src/components/access-control-modal/AccessControlModal') }) + describe('access-control URL predicates', () => { + it.each([ + 'http://example.com', + 'https://example.com/path', + 'https://example.com:8443/resource' + ])('accepts an absolute HTTP URI: %s', value => { + expect(isAbsoluteHttpUri(value)).toBe(true) + }) + + it.each([ + 'example.com', + '/relative/path', + 'mailto:alice@example.com', + 'not a URL' + ])('rejects a non-absolute HTTP URI: %s', value => { + expect(isAbsoluteHttpUri(value)).toBe(false) + }) + + it.each([ + 'http://example.com', + 'https://example.com/', + 'https://example.com:8443' + ])('recognizes an origin URL: %s', value => { + expect(isOriginUrl(value)).toBe(true) + }) + + it.each([ + 'https://example.com/path', + 'https://example.com?query=value', + 'https://user:password@example.com' + ])('rejects a URL with non-origin components: %s', value => { + expect(isOriginUrl(value)).toBe(false) + }) + }) + it('queues a grant on submit without saving it', async () => { const element = document.createElement('solid-ui-access-control-modal') as any element.subjectUri = 'https://example.com/resource.ttl' @@ -75,6 +111,25 @@ describe('AccessControlModal submit', () => { expect(element.principalInputValue).toBe('') }) + it.each([ + ['app.example', 'https://app.example'], + ['www.sharonstratsianis.com', 'https://www.sharonstratsianis.com'] + ])('queues a bare origin as %s', async (input, origin) => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/resource.ttl' + element.principalInputValue = input + + await element.onSubmit(new Event('submit')) + + expect(classifyAccessControlSubject).not.toHaveBeenCalled() + expect(element.pendingAccessGrants).toEqual([ + expect.objectContaining({ + subjectType: 'origin', + subjectValue: origin + }) + ]) + }) + it('keeps newer input when an async lookup resolves later', async () => { let resolveClassification!: (value: { kind: 'agent', subjectValue: string }) => void classifyAccessControlSubject.mockImplementationOnce(() => new Promise(resolve => { @@ -140,6 +195,26 @@ describe('AccessControlModal submit', () => { expect(element.pendingAccessGrants).toEqual([]) }) + it('passes a queued origin grant to planGrant on save', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/resource.ttl' + element.principalInputValue = 'www.sharonstratsianis.com' + element.addAccessRoleValue = 'Editor' + + await element.onSaveClick() + + expect(planGrant).toHaveBeenCalledWith( + 'https://example.com/resource.ttl', + { + type: 'origin', + iri: 'https://www.sharonstratsianis.com' + }, + ['Read', 'Write'] + ) + expect(applyPlan).toHaveBeenCalledTimes(1) + expect(element.pendingAccessGrants).toEqual([]) + }) + it.each([ { role: 'Owner', modes: ['Read', 'Write', 'Control'] }, { role: 'Editor', modes: ['Read', 'Write'] }, @@ -276,6 +351,7 @@ describe('AccessControlModal submit', () => { agent: ['https://alice.example.com/profile/card.ttl#me'], agentGroup: [], agentClass: [], + origin: [], mode: ['Read'] }] @@ -333,6 +409,7 @@ describe('AccessControlModal submit', () => { agent: ['https://alice.example.com/profile/card.ttl#me'], agentGroup: [], agentClass: [], + origin: [], mode: ['Read'] }] @@ -355,18 +432,21 @@ describe('AccessControlModal submit', () => { agent: ['https://alice.example.com/profile/card.ttl#me'], agentGroup: [], agentClass: [], + origin: [], mode: ['Read'] }, { agent: [], agentGroup: [], agentClass: ['http://www.w3.org/ns/auth/acl#AuthenticatedAgent'], + origin: [], mode: ['Read', 'Write'] }, { agent: [], agentGroup: [], agentClass: ['http://xmlns.com/foaf/0.1/Agent'], + origin: [], mode: ['Read'] } ] @@ -397,6 +477,7 @@ describe('AccessControlModal submit', () => { agent: [agent], agentGroup: [group], agentClass: [generalClass, agentClass], + origin: [], mode: ['Read'] }] @@ -409,6 +490,7 @@ describe('AccessControlModal submit', () => { agent: [agent], agentGroup: [group], agentClass: [agentClass], + origin: [], mode: ['Read'] }) const entries = element.getSharedAccessGrantEntries() @@ -449,6 +531,7 @@ describe('AccessControlModal submit', () => { agent: ['https://alice.example.com/profile/card.ttl#me'], agentGroup: [], agentClass: [], + origin: [], mode: ['Read'] }] @@ -474,6 +557,7 @@ describe('AccessControlModal submit', () => { }) it('aborts saving when principal lookup fails', async () => { + classifyAccessControlSubject.mockResolvedValueOnce(undefined) const element = document.createElement('solid-ui-access-control-modal') as any element.subjectUri = 'https://example.com/resource.ttl' element.principalInputValue = 'not-a-principal' @@ -500,6 +584,7 @@ describe('AccessControlModal submit', () => { agent: ['https://alice.example.com/profile/card.ttl#me'], agentGroup: ['https://example.com/group'], agentClass: [], + origin: [], mode: ['Read'] }] From c55cf7c36aaf1592b3248c2d83ad3e2062610e88 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 11 Oct 2026 07:51:45 +0000 Subject: [PATCH 2/2] chore: update solidos dependencies (dev: solid-logic@6.0.1-1 pane-registry@5.0.2-0) (latest: rdflib@2.4.1) --- package-lock.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index b99773310..e254bc1f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -91,9 +91,9 @@ } }, "node_modules/@acemir/cssom": { - "version": "0.9.31", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", - "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "version": "0.9.32", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz", + "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==", "devOptional": true, "license": "MIT" }, @@ -5366,9 +5366,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.19.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz", - "integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==", + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -11067,9 +11067,9 @@ "license": "MIT" }, "node_modules/n3": { - "version": "2.13.11", - "resolved": "https://registry.npmjs.org/n3/-/n3-2.13.11.tgz", - "integrity": "sha512-RZ+iINCXqZm4Tr6rA1/WQI573hPrwbkiWQspz5TViIWLWEs5y6+AVWoNQ7preieLhqdi9H1E+AOR+LvlhKxsOg==", + "version": "2.13.12", + "resolved": "https://registry.npmjs.org/n3/-/n3-2.13.12.tgz", + "integrity": "sha512-z944NsyJi58WXcYevtNkUL2JhVr0KP6nvED9AgymQq5V35TDaNn1N8kO+OWJk/kj4PR0L/ilWB3d7/bWceg1CQ==", "license": "MIT", "dependencies": { "buffer": "^6.0.3", @@ -13910,9 +13910,9 @@ } }, "node_modules/uuid": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", - "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.3.tgz", + "integrity": "sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan"