Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ configure(subprojects.filter { it.name in configModuleProjects }) {
}
val configModuleTitles = mapOf(
"onec-framework-starter" to "Core — `onec-framework-starter` (`OnecProperties`, prefix `onec`)",
"onec-ui-starter" to "UI — `onec-ui-starter` (`UiProperties` prefix `onec.ui`, `MediaProperties` prefix `onec.media`, `CommentProperties` prefix `onec.comments`)",
"onec-ui-starter" to "UI — `onec-ui-starter` (`UiProperties` prefix `onec.ui`, `MediaProperties` prefix `onec.media`, `CommentProperties` prefix `onec.comments`, `AppLinksProperties` prefix `onec.app-links`)",
"onec-auth-starter" to "Auth — `onec-auth-starter` (`OnecAuthProperties`, prefix `onec.auth`)",
"onec-mcp-starter" to "MCP — `onec-mcp-starter` (`OnecMcpProperties`, prefix `onec.mcp`)",
"onec-import-starter" to "Import — `onec-import-starter` (`OnecImportProperties`, prefix `onec.import`)",
Expand Down
5 changes: 4 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ contract (column-name keys, `{col}_display`/`{col}_ref` expansion, `__SECRET_SET
| Mentions | `GET /api/mentions?q=` — cross-entity `@`-mention typeahead over readable catalogs/documents; comment bodies carry mentions as `@[Display](kind/name/id)` tokens resolved live, and each readable mention publishes `EntityMentionedEvent` (no consumers — additive via `@EventListener`) (ui-starter) |
| DivKit UI | `GET /api/divkit/{shell,home,menu,account,settings}` and `/api/divkit/{catalogs,documents}/{name}[/{id}|/new|/{id}/edit]`, `/api/divkit/registers/{name}` (ui-starter) |
| Theme/config | `GET /api/theme`, `GET /api/config`, `GET /api/branding` (ui-starter) |
| App links | `GET /.well-known/apple-app-site-association`, `GET /.well-known/assetlinks.json` — public, unauthenticated `application/json` so iOS/Android verify the domain and open the mobile app from its links; 404 until `onec.app-links` is set (ui-starter) |
| Events | `GET /api/events` — SSE stream of CRUD/posting changes (ui-starter) |
| Auth | `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`, `GET /api/auth/csrf` (auth-starter) |
| Import | `POST /api/import/{catalogs,documents}/{name}/csv[/preview]` (import-starter) |
Expand Down Expand Up @@ -235,7 +236,9 @@ and `config(key,value)` reference.

`/api/**` requires authentication (except the public allowlist: `/error`, `/api/theme`,
`/api/config`, `/api/branding`, `/api/auth/login`, `/api/auth/me`, `/api/auth/csrf`,
`/api/divkit/login`, `/api/desktop/**`). **Per-entity RBAC is deny-by-default**: a
`/api/divkit/login`, `/api/desktop/**`). Everything outside `/api/**` is public — including the SPA
shell and the `/.well-known/*` app-association files (which the OS must fetch unauthenticated and
without a redirect). **Per-entity RBAC is deny-by-default**: a
catalog/document/register is invisible
and uneditable unless its `@AccessControl` read/write roles grant the caller; the `ADMIN` role is a
superuser. Override the whole thing by setting `onec.auth.enabled=false` and supplying your own
Expand Down
4 changes: 3 additions & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ except Kafka inbound). Standard Spring keys (`spring.datasource.*`, `spring.mail
| `onec.schema.mode` | `String` | `apply` | What to do about differences between the metadata model and the database at startup: `apply` (default — execute safe changes, report destructive ones), `plan` (log the plan, change nothing), `validate` (fail startup on any difference or unapplied migration), or `off`. |
| `onec.security.secret-key` | `String` | — | Encryption key for `@Attribute(secret = true)` values. Any passphrase works (it is hashed to a 256-bit AES key). Required only when an entity declares a secret attribute; supply it from an environment variable, never hard-code it. |

## UI — `onec-ui-starter` (`UiProperties` prefix `onec.ui`, `MediaProperties` prefix `onec.media`, `CommentProperties` prefix `onec.comments`)
## UI — `onec-ui-starter` (`UiProperties` prefix `onec.ui`, `MediaProperties` prefix `onec.media`, `CommentProperties` prefix `onec.comments`, `AppLinksProperties` prefix `onec.app-links`)

| Property | Type | Default | Meaning |
| --- | --- | --- | --- |
| `onec.app-links.android` | `List<AndroidApp>` | — | Android apps allowed to handle this domain's links, served in `/.well-known/assetlinks.json`. Empty → that endpoint 404s. |
| `onec.app-links.apple-app-ids` | `List<String>` | — | Apple `appID`s (each `TeamID.bundleId`) allowed to handle this domain's links, served in `/.well-known/apple-app-site-association`. Empty → that endpoint 404s. |
| `onec.comments.enabled` | `Boolean` | `true` | Whether the comments endpoint, its storage table, and the detail-page comments panel are wired at all. Turn it off to drop the feature from every entity without touching the model. |
| `onec.comments.max-length` | `Integer` | `4000` | Largest comment body accepted, in characters. The server rejects a longer body with 422; the compose box mirrors the limit client-side. Defaults to 4000. |
| `onec.comments.mentions.enabled` | `Boolean` | `true` | Whether `@`-mentions are parsed, resolved and offered in the compose typeahead. Turn it off to keep plain-text comments without touching `onec.comments.enabled`; existing mention tokens then degrade to their plain label text. |
Expand Down
11 changes: 11 additions & 0 deletions example/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ onec:
# composes its own SettingsPage at /settings, so turn the surface + admin nav entry on.
settings:
enabled: true
# Let the onec-mobile app open from this server's https:// links (iOS Universal Links / Android
# App Links). The two /.well-known files 404 until set; values are deployment-specific (Apple Team
# ID, app bundle id / package, signing-cert SHA-256). Self-hosted servers can set these, but the
# app only registers *.cloud.onno.su at build time — others fall back to the onec:// scheme / QR.
# app-links:
# apple-app-ids:
# - "ABCDE12345.su.onno.onec.mobile" # <TeamID>.<bundleId>
# android:
# - package-name: su.onno.onec.mobile
# sha256-cert-fingerprints:
# - "AB:CD:EF:..."
auth:
# Authentication backend. Default is "in-memory" (the onec.auth.users below). Set to
# "oidc" for server-side OIDC login (Keycloak, Zitadel, …; keeps the session cookie, "Sign in"
Expand Down
34 changes: 34 additions & 0 deletions onec-ui-starter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ controller, and a static-resource handler that serves the bundled frontend from
| `onec.ui.update-check.url` | `https://cloud.onno.su/releases/v1/latest` | Release-announcement endpoint to poll. |
| `onec.ui.update-check.interval` | `24h` | Cadence after the first check (floored at 60s). |
| `onec.ui.update-check.initial-delay` | `1m` | Delay before the first check. |
| `onec.app-links.apple-app-ids` | empty | iOS `appID`s (`<TeamID>.<bundleId>`) served from `/.well-known/apple-app-site-association` for Universal Links. Empty → 404. |
| `onec.app-links.android` | empty | Android apps (`package-name` + `sha256-cert-fingerprints`) served from `/.well-known/assetlinks.json` for App Links. Empty → 404. |

### Update-available notice

Expand All @@ -55,6 +57,38 @@ unparseable body, unknown local version) is swallowed, so a flaky network never
alarming notice. The endpoint it polls is served by onec-cloud (`GET /releases/v1/latest`), which
returns `204` when no release is announced.

### Mobile app links — `/.well-known`

So a native app (e.g. [onec-mobile](https://github.com/onec-erp/onec-mobile)) can open from this
domain's `https://` links instead of the browser, the starter serves the two association files the
OS fetches:

| Endpoint | For |
|----------|-----|
| `GET /.well-known/apple-app-site-association` | iOS Universal Links |
| `GET /.well-known/assetlinks.json` | Android App Links |

Both are **public, unauthenticated `application/json`, no redirect** — the OS fetches them anonymously
and rejects the association on any login wall or redirect. They live outside `/api/**` (public by
default) and, being explicit controller mappings, win over the SPA's `index.html` fallback. Each
answers `404` until configured, so an unconfigured deployment advertises no association:

```yaml
onec:
app-links:
apple-app-ids:
- "ABCDE12345.su.onno.onec.mobile" # <TeamID>.<bundleId>
android:
- package-name: su.onno.onec.mobile
sha256-cert-fingerprints:
- "AB:CD:EF:..." # signing-cert SHA-256
```

The values are deployment-specific (Apple Team ID, app bundle id / package, signing-cert
fingerprint). The cloud serves these from every tenant subdomain automatically; self-hosted servers
on their own domains can set them too, but the app only registers the cloud's parent domain at build
time, so self-hosted onboarding uses the app's `onec://` custom scheme / QR instead.

## REST endpoints

All endpoints are under `/api/**`. Entity-scoped paths take a `{name}` that is the entity's
Expand Down
87 changes: 87 additions & 0 deletions onec-ui-starter/src/main/java/com/onec/ui/AppLinksProperties.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package com.onec.ui;

import org.springframework.boot.context.properties.ConfigurationProperties;

import java.util.ArrayList;
import java.util.List;

/**
* App-association data served from the two {@code /.well-known} files so a native mobile app can
* claim this domain's {@code https://} links (iOS Universal Links, Android App Links) and open
* directly instead of the browser. Both lists are empty by default, in which case the corresponding
* endpoint answers {@code 404} — universal links are opt-in and require deployment-specific identity
* and signing values that only the operator has.
*
* <pre>
* onec:
* app-links:
* apple-app-ids: # iOS: "&lt;TeamID&gt;.&lt;bundleId&gt;"
* - "ABCDE12345.su.onno.onec.mobile"
* android: # Android: package + signing-cert SHA-256 fingerprint(s)
* - package-name: su.onno.onec.mobile
* sha256-cert-fingerprints:
* - "AB:CD:EF:..."
* </pre>
*
* @see WellKnownController
*/
@ConfigurationProperties(prefix = "onec.app-links")
public class AppLinksProperties {

/**
* Apple {@code appID}s (each {@code TeamID.bundleId}) allowed to handle this domain's links,
* served in {@code /.well-known/apple-app-site-association}. Empty → that endpoint 404s.
*/
private List<String> appleAppIds = new ArrayList<>();

/**
* Android apps allowed to handle this domain's links, served in {@code /.well-known/assetlinks.json}.
* Empty → that endpoint 404s.
*/
private List<AndroidApp> android = new ArrayList<>();

public List<String> getAppleAppIds() {
return appleAppIds;
}

public void setAppleAppIds(List<String> appleAppIds) {
this.appleAppIds = appleAppIds;
}

public List<AndroidApp> getAndroid() {
return android;
}

public void setAndroid(List<AndroidApp> android) {
this.android = android;
}

/** One Android app's identity in an {@code assetlinks.json} statement. */
public static class AndroidApp {

/** The application package name, e.g. {@code su.onno.onec.mobile}. */
private String packageName;

/**
* SHA-256 fingerprints of the signing certificate(s) (upper-case, colon-separated). Multiple
* allow rotating keys or distinct debug/release/upload certs to all verify the same domain.
*/
private List<String> sha256CertFingerprints = new ArrayList<>();

public String getPackageName() {
return packageName;
}

public void setPackageName(String packageName) {
this.packageName = packageName;
}

public List<String> getSha256CertFingerprints() {
return sha256CertFingerprints;
}

public void setSha256CertFingerprints(List<String> sha256CertFingerprints) {
this.sha256CertFingerprints = sha256CertFingerprints;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@AutoConfiguration(after = OnecAutoConfiguration.class)
@EnableConfigurationProperties({UiProperties.class, UpdateProperties.class})
@EnableConfigurationProperties({UiProperties.class, UpdateProperties.class, AppLinksProperties.class})
@ConditionalOnBean(MetadataRegistry.class)
@ConditionalOnProperty(prefix = "onec.ui", name = "enabled", havingValue = "true", matchIfMissing = true)
public class UiAutoConfiguration implements WebMvcConfigurer {
Expand All @@ -41,6 +41,15 @@ public SpaIndexController spaIndexController() {
return new SpaIndexController(spaIndexHtml);
}

/**
* Serves the {@code /.well-known} app-association files (iOS Universal Links / Android App
* Links). Always wired; the endpoints 404 until {@code onec.app-links} is configured.
*/
@Bean
public WellKnownController wellKnownController(AppLinksProperties appLinksProperties) {
return new WellKnownController(appLinksProperties);
}

@Bean
public SettingsController settingsController(MetadataRegistry registry,
com.onec.repository.ConstantManager constantManager,
Expand Down
91 changes: 91 additions & 0 deletions onec-ui-starter/src/main/java/com/onec/ui/WellKnownController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package com.onec.ui;

import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
* Serves the two {@code /.well-known} app-association files that let a native mobile app claim this
* domain's {@code https://} links — iOS Universal Links and Android App Links — so a tapped link
* opens the app instead of the browser.
*
* <p>These must be reachable <em>unauthenticated</em>, as {@code application/json}, with no redirect:
* the OS fetches them anonymously and rejects the association on any auth wall or redirect. They sit
* outside {@code /api/**}, which the auth starter leaves public by default, and being an explicit
* controller mapping they take precedence over the SPA's {@code /**} index.html fallback (which would
* otherwise answer these paths with HTML).
*
* <p>Both endpoints answer {@code 404} until the matching identities are configured under
* {@code onec.app-links} (see {@link AppLinksProperties}) — so an unconfigured deployment advertises
* no association rather than a broken/empty one.
*/
@RestController
public class WellKnownController {

private final AppLinksProperties properties;

public WellKnownController(AppLinksProperties properties) {
this.properties = properties;
}

/**
* iOS {@code apple-app-site-association} (note: no file extension). Grants the configured
* {@code appID}s Universal-Link handling for every path on this domain.
*/
@GetMapping(value = "/.well-known/apple-app-site-association", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> appleAppSiteAssociation() {
List<String> appIds = properties.getAppleAppIds();
if (appIds == null || appIds.isEmpty()) {
return ResponseEntity.notFound().build();
}
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("appIDs", new ArrayList<>(appIds));
// A single component matching every path. `components` (iOS 13+) is the modern form; the
// wildcard-subdomain entitlement this pairs with already requires iOS 14+.
detail.put("components", List.of(Map.of("/", "*", "comment", "Open every path in the app")));

Map<String, Object> applinks = new LinkedHashMap<>();
applinks.put("apps", List.of()); // required-empty in the legacy schema; harmless for new parsers
applinks.put("details", List.of(detail));

return ResponseEntity.ok().body(Map.of("applinks", applinks));
}

/**
* Android {@code assetlinks.json}. One {@code handle_all_urls} statement per configured app,
* binding its package + signing-cert fingerprint(s) to this domain.
*/
@GetMapping(value = "/.well-known/assetlinks.json", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<List<Map<String, Object>>> assetLinks() {
List<AppLinksProperties.AndroidApp> apps = properties.getAndroid();
if (apps == null || apps.isEmpty()) {
return ResponseEntity.notFound().build();
}
List<Map<String, Object>> statements = new ArrayList<>();
for (AppLinksProperties.AndroidApp app : apps) {
if (app.getPackageName() == null || app.getPackageName().isBlank()
|| app.getSha256CertFingerprints() == null || app.getSha256CertFingerprints().isEmpty()) {
continue; // an entry missing its package or fingerprints can't verify — skip it
}
Map<String, Object> target = new LinkedHashMap<>();
target.put("namespace", "android_app");
target.put("package_name", app.getPackageName());
target.put("sha256_cert_fingerprints", new ArrayList<>(app.getSha256CertFingerprints()));

Map<String, Object> statement = new LinkedHashMap<>();
statement.put("relation", List.of("delegate_permission/common.handle_all_urls"));
statement.put("target", target);
statements.add(statement);
}
if (statements.isEmpty()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok().body(statements);
}
}
Loading